From 9f828473f61402708cba911917dbe0ddf59bfa4c Mon Sep 17 00:00:00 2001 From: inmake Date: Mon, 16 Mar 2026 18:59:49 +0500 Subject: [PATCH] Enhance Button and CreateBuildModal components; add title prop to Button for accessibility, refactor CreateBuildModal to manage multiple builds selection, improve loading states, and streamline API interactions for better user experience. --- client/src/components/Button.tsx | 3 + .../components/modals/CreateBuildModal.tsx | 178 +++++++++++------- client/src/pages/AdminCompanyPage.tsx | 37 +++- client/src/types/IBuild.ts | 1 - server/package.json | 3 +- .../migrate-builds-to-company-build.ts | 67 +++++++ server/src/index.ts | 22 ++- server/src/middlewares/adminOnly.ts | 15 ++ server/src/models/Build.ts | 4 - server/src/models/Company.ts | 4 +- server/src/models/CompanyBuild.ts | 25 +++ server/src/routes/admin/adminBuildsRoute.ts | 78 +++++++- .../src/routes/admin/adminCompaniesRoute.ts | 70 ++++++- server/src/routes/admin/adminUsersRoute.ts | 58 +++++- server/src/routes/companies.ts | 21 ++- server/src/utils/adminUtils.ts | 28 +++ 16 files changed, 514 insertions(+), 100 deletions(-) create mode 100644 server/scripts/migrate-builds-to-company-build.ts create mode 100644 server/src/middlewares/adminOnly.ts create mode 100644 server/src/models/CompanyBuild.ts create mode 100644 server/src/utils/adminUtils.ts diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx index dfc58f2..897eaa2 100644 --- a/client/src/components/Button.tsx +++ b/client/src/components/Button.tsx @@ -13,6 +13,7 @@ interface Props { disabled?: boolean; loading?: boolean; onClick?: () => void; + title?: string; } const variantClasses = { @@ -58,11 +59,13 @@ function Button({ disabled, loading, onClick, + title, }: Props) { return ( - - + + ); + } + + return ( +
+

Выбор ЖК

+

+ Выберите один или несколько ЖК для добавления в компанию +

+
+ {builds.map((build) => ( + + ))} +
+
+ + +
); } diff --git a/client/src/pages/AdminCompanyPage.tsx b/client/src/pages/AdminCompanyPage.tsx index f82cfb5..628a277 100644 --- a/client/src/pages/AdminCompanyPage.tsx +++ b/client/src/pages/AdminCompanyPage.tsx @@ -13,6 +13,7 @@ import CreateUserModal from "../components/modals/CreateUserModal"; import { Transition } from "react-transition-group"; import SpinnerIcon from "../components/icons/SpinnerIcon"; import MoreIcon from "../components/icons/MoreIcon"; +import CloseIcon from "../components/icons/CloseIcon"; import EditUserModal from "../components/modals/EditUserModal"; function AdminCompanyPage() { @@ -61,6 +62,29 @@ function AdminCompanyPage() { } } + async function removeBuildFromCompany(buildId: string) { + if ( + !companyId || + !confirm("Убрать ЖК из компании? ЖК останется в каталоге.") + ) + return; + + try { + const result: { error?: string } | { success?: boolean } = await api + .delete(`admin/companies/${companyId}/builds/${buildId}`) + .json(); + + if ("error" in result) { + alert(result.error); + return; + } + + getBuilds(); + } catch (error) { + alert((error as Error).message); + } + } + useEffect(() => { getBuilds(); getUsers(); @@ -100,7 +124,10 @@ function AdminCompanyPage() {
{builds && builds.map((build) => ( -
+
{build.build}

+
))}
diff --git a/client/src/types/IBuild.ts b/client/src/types/IBuild.ts index a4fa301..b0332a6 100644 --- a/client/src/types/IBuild.ts +++ b/client/src/types/IBuild.ts @@ -1,6 +1,5 @@ interface IBuild { id: string; - companyId: string; build: string; name: string; } diff --git a/server/package.json b/server/package.json index d05152f..ecb479c 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "nodemon --exec node --import=./register.js ./src/index.ts", "build": "npx tsc -p ./", - "start": "node ./dist/index.js" + "start": "node ./dist/index.js", + "migrate:builds": "node --import=./register.js ./scripts/migrate-builds-to-company-build.ts" }, "dependencies": { "bcrypt": "^5.1.1", diff --git a/server/scripts/migrate-builds-to-company-build.ts b/server/scripts/migrate-builds-to-company-build.ts new file mode 100644 index 0000000..dbeb99e --- /dev/null +++ b/server/scripts/migrate-builds-to-company-build.ts @@ -0,0 +1,67 @@ +/** + * Миграция: создание связей CompanyBuild из старых Build с companyId + * + * Запуск: npx ts-node --esm scripts/migrate-builds-to-company-build.ts + * (из папки server, с загруженным .env) + */ +import "dotenv/config"; +import mongoose from "mongoose"; +import Build from "../src/models/Build.js"; +import CompanyBuild from "../src/models/CompanyBuild.js"; + +async function migrate() { + await mongoose.connect(process.env.MONGO_URI!, { dbName: "crm_stream" }); + console.log("MongoDB connected"); + + const db = mongoose.connection.db; + if (!db) throw new Error("No database connection"); + + const buildsCollection = db.collection("builds"); + const buildsWithCompany = await buildsCollection + .find({ companyId: { $exists: true, $ne: null } }) + .toArray(); + + console.log(`Найдено ${buildsWithCompany.length} сборок с companyId`); + + let created = 0; + let skipped = 0; + + for (const build of buildsWithCompany) { + const companyId = build.companyId; + const buildId = build._id; + + const existing = await CompanyBuild.findOne({ + companyId, + buildId, + }); + + if (existing) { + skipped++; + continue; + } + + await CompanyBuild.create({ + companyId: new mongoose.Types.ObjectId(companyId.toString()), + buildId: new mongoose.Types.ObjectId(buildId.toString()), + }); + created++; + console.log(` Создана связь: company ${companyId} -> build ${buildId}`); + } + + console.log(`\nГотово. Создано: ${created}, пропущено (уже есть): ${skipped}`); + + // Удаляем companyId из документов Build (опционально, для консистентности) + const unsetResult = await buildsCollection.updateMany( + { companyId: { $exists: true } }, + { $unset: { companyId: "" } } + ); + console.log(`Удалено поле companyId из ${unsetResult.modifiedCount} документов Build`); + + await mongoose.disconnect(); + process.exit(0); +} + +migrate().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/src/index.ts b/server/src/index.ts index ece924e..fe64069 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import connectDB from "./config/db.js"; import cors from "cors"; import cookieParser from "cookie-parser"; import authMiddleware from "./middlewares/auth.js"; +import adminOnlyMiddleware from "./middlewares/adminOnly.js"; import registerRoute from "./routes/register.js"; import refreshRoute from "./routes/refresh.js"; import checkRoute from "./routes/check.js"; @@ -46,9 +47,24 @@ app.use("/resetConfirm", resetConfirmRoute); app.use("/actions", actionsRouter); app.use("/builds", buildsRouter); app.use("/scheduledSessions", scheduledSessionsRoute); -app.use("/admin/companies", adminCompaniesRoute); -app.use("/admin/builds", adminBuildsRoute); -app.use("/admin/users", adminUsersRoute); +app.use( + "/admin/companies", + authMiddleware, + adminOnlyMiddleware, + adminCompaniesRoute +); +app.use( + "/admin/builds", + authMiddleware, + adminOnlyMiddleware, + adminBuildsRoute +); +app.use( + "/admin/users", + authMiddleware, + adminOnlyMiddleware, + adminUsersRoute +); app.use("/companies", authMiddleware, companiesRouter); app.use("/users", authMiddleware, usersRouter); app.use("/changePassword", authMiddleware, changePasswordRoute); diff --git a/server/src/middlewares/adminOnly.ts b/server/src/middlewares/adminOnly.ts new file mode 100644 index 0000000..de612f8 --- /dev/null +++ b/server/src/middlewares/adminOnly.ts @@ -0,0 +1,15 @@ +import { Request, Response, NextFunction } from "express"; + +const ADMIN_USERNAME = "admin@graff.tech"; + +function adminOnlyMiddleware(req: Request, res: Response, next: NextFunction) { + const user = res.locals.user; + + if (!user || user.username !== ADMIN_USERNAME) { + return res.status(403).json({ error: "Доступ запрещён" }); + } + + next(); +} + +export default adminOnlyMiddleware; diff --git a/server/src/models/Build.ts b/server/src/models/Build.ts index 79d8741..4cd7e63 100644 --- a/server/src/models/Build.ts +++ b/server/src/models/Build.ts @@ -2,10 +2,6 @@ import { model, Schema } from "mongoose"; const buildSchema = new Schema( { - companyId: { - type: Schema.Types.ObjectId, - required: true, - }, name: { type: String, required: true, diff --git a/server/src/models/Company.ts b/server/src/models/Company.ts index afa4abe..bf3c46f 100644 --- a/server/src/models/Company.ts +++ b/server/src/models/Company.ts @@ -46,8 +46,8 @@ companySchema.virtual("users", { foreignField: "companyId", }); -companySchema.virtual("builds", { - ref: "Build", +companySchema.virtual("companyBuilds", { + ref: "CompanyBuild", localField: "_id", foreignField: "companyId", }); diff --git a/server/src/models/CompanyBuild.ts b/server/src/models/CompanyBuild.ts new file mode 100644 index 0000000..01943d1 --- /dev/null +++ b/server/src/models/CompanyBuild.ts @@ -0,0 +1,25 @@ +import { model, Schema } from "mongoose"; + +const companyBuildSchema = new Schema( + { + companyId: { + type: Schema.Types.ObjectId, + ref: "Company", + required: true, + }, + buildId: { + type: Schema.Types.ObjectId, + ref: "Build", + required: true, + }, + }, + { + timestamps: true, + } +); + +companyBuildSchema.index({ companyId: 1, buildId: 1 }, { unique: true }); + +const CompanyBuild = model("CompanyBuild", companyBuildSchema); + +export default CompanyBuild; diff --git a/server/src/routes/admin/adminBuildsRoute.ts b/server/src/routes/admin/adminBuildsRoute.ts index d5d886c..d2978b6 100644 --- a/server/src/routes/admin/adminBuildsRoute.ts +++ b/server/src/routes/admin/adminBuildsRoute.ts @@ -1,12 +1,45 @@ import { Router } from "express"; import Build from "../../models/Build.js"; +import CompanyBuild from "../../models/CompanyBuild.js"; +import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js"; const router = Router(); +const BUILD_FIELDS = ["name", "build"]; + router.get("/", async (req, res) => { try { - const result = await Build.find(req.query); + const query = req.query as Record; + const companyId = query.companyId as string | undefined; + const availableForCompany = query.availableForCompany as string | undefined; + if (availableForCompany) { + // Список сборок из каталога, которые ещё не привязаны к компании + const linked = await CompanyBuild.find({ + companyId: availableForCompany, + }).distinct("buildId"); + const result = await Build.find({ _id: { $nin: linked } }); + return res.json(result); + } + + if (companyId) { + // Сборки, привязанные к компании (через CompanyBuild) + const companyBuilds = await CompanyBuild.find({ companyId }) + .populate("buildId") + .lean(); + const builds = companyBuilds + .map((cb) => cb.buildId) + .filter(Boolean) + .map((b) => { + const doc = b as unknown as { _id?: { toString: () => string }; id?: string }; + return { ...doc, id: doc.id ?? (doc._id ? doc._id.toString() : undefined) }; + }); + return res.json(builds); + } + + // Все сборки из каталога + const filter = sanitizeAdminQuery(query, "builds"); + const result = await Build.find(filter); res.json(result); } catch (error) { res.json({ error: (error as Error).message }); @@ -15,6 +48,10 @@ router.get("/", async (req, res) => { router.get("/:id", async (req, res) => { try { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + const result = await Build.findById(req.params.id); res.json(result); @@ -25,7 +62,27 @@ router.get("/:id", async (req, res) => { router.post("/", async (req, res) => { try { - const result = await Build.create(req.body); + const { companyId, buildId } = req.body; + + if (companyId && buildId) { + // Создание связи компания-сборка (many-to-many) + if (!isValidObjectId(companyId) || !isValidObjectId(buildId)) { + return res.status(400).json({ error: "Некорректный ID компании или сборки" }); + } + await CompanyBuild.create({ companyId, buildId }); + const build = await Build.findById(buildId); + return res.json(build); + } + + // Создание новой сборки в каталоге + const body = Object.fromEntries( + BUILD_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [ + k, + req.body[k], + ]) + ); + + const result = await Build.create(body); res.json(result); } catch (error) { @@ -35,7 +92,18 @@ router.post("/", async (req, res) => { router.put("/:id", async (req, res) => { try { - const result = await Build.findByIdAndUpdate(req.params.id, req.body, { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + + const body = Object.fromEntries( + BUILD_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [ + k, + req.body[k], + ]) + ); + + const result = await Build.findByIdAndUpdate(req.params.id, body, { new: true, }); @@ -47,6 +115,10 @@ router.put("/:id", async (req, res) => { router.delete("/:id", async (req, res) => { try { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + const result = await Build.findByIdAndRemove(req.params.id); res.json(result); diff --git a/server/src/routes/admin/adminCompaniesRoute.ts b/server/src/routes/admin/adminCompaniesRoute.ts index b6a0208..e80ee10 100644 --- a/server/src/routes/admin/adminCompaniesRoute.ts +++ b/server/src/routes/admin/adminCompaniesRoute.ts @@ -1,11 +1,26 @@ import { Router } from "express"; import Company from "../../models/Company.js"; +import CompanyBuild from "../../models/CompanyBuild.js"; +import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js"; const router = Router(); +const COMPANY_FIELDS = [ + "name", + "sessionLimit", + "avatar", + "phone", + "site", + "email", + "address", + "startTime", + "endTime", +]; + router.get("/", async (req, res) => { try { - const result = await Company.find(req.query); + const filter = sanitizeAdminQuery(req.query as Record, "companies"); + const result = await Company.find(filter); res.json(result); } catch (error) { @@ -15,6 +30,10 @@ router.get("/", async (req, res) => { router.get("/:id", async (req, res) => { try { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + const result = await Company.findById(req.params.id); res.json(result); @@ -25,7 +44,14 @@ router.get("/:id", async (req, res) => { router.post("/", async (req, res) => { try { - const result = await Company.create(req.body); + const body = Object.fromEntries( + COMPANY_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [ + k, + req.body[k], + ]) + ); + + const result = await Company.create(body); res.json(result); } catch (error) { @@ -35,7 +61,18 @@ router.post("/", async (req, res) => { router.put("/:id", async (req, res) => { try { - const result = await Company.findByIdAndUpdate(req.params.id, req.body, { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + + const body = Object.fromEntries( + COMPANY_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [ + k, + req.body[k], + ]) + ); + + const result = await Company.findByIdAndUpdate(req.params.id, body, { new: true, }); @@ -45,8 +82,35 @@ router.put("/:id", async (req, res) => { } }); +router.delete("/:companyId/builds/:buildId", async (req, res) => { + try { + const { companyId, buildId } = req.params; + + if (!isValidObjectId(companyId) || !isValidObjectId(buildId)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + + const result = await CompanyBuild.findOneAndDelete({ + companyId, + buildId, + }); + + if (!result) { + return res.status(404).json({ error: "Связь не найдена" }); + } + + res.json({ success: true }); + } catch (error) { + res.json({ error: (error as Error).message }); + } +}); + router.delete("/:id", async (req, res) => { try { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + const result = await Company.findByIdAndRemove(req.params.id); res.json(result); diff --git a/server/src/routes/admin/adminUsersRoute.ts b/server/src/routes/admin/adminUsersRoute.ts index cca78e2..aa7ed59 100644 --- a/server/src/routes/admin/adminUsersRoute.ts +++ b/server/src/routes/admin/adminUsersRoute.ts @@ -1,12 +1,23 @@ import bcrypt from "bcrypt"; import { Router } from "express"; import User from "../../models/User.js"; +import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js"; const router = Router(); +const USER_FIELDS = ["username", "companyId", "role", "name", "buildIds"]; + +function toSafeUser(doc: { toJSON?: () => Record } | null) { + if (!doc) return null; + const json = doc.toJSON ? doc.toJSON() : (doc as Record); + const { password, resetCode, ...safe } = json as Record; + return safe; +} + router.get("/", async (req, res) => { try { - const result = await User.find(req.query); + const filter = sanitizeAdminQuery(req.query as Record, "users"); + const result = await User.find(filter).select("-password -resetCode"); res.json(result); } catch (error) { @@ -16,7 +27,11 @@ router.get("/", async (req, res) => { router.get("/:id", async (req, res) => { try { - const result = await User.findById(req.params.id); + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + + const result = await User.findById(req.params.id).select("-password -resetCode"); res.json(result); } catch (error) { @@ -26,10 +41,20 @@ router.get("/:id", async (req, res) => { router.post("/", async (req, res) => { try { - const passwordHash = bcrypt.hashSync(req.body.password, 12); - const result = await User.create({ ...req.body, password: passwordHash }); + if (!req.body.password || typeof req.body.password !== "string") { + return res.status(400).json({ error: "Пароль обязателен" }); + } - res.json(result); + const passwordHash = bcrypt.hashSync(req.body.password, 12); + const body = Object.fromEntries( + [...USER_FIELDS, "password"] + .filter((k) => req.body[k] !== undefined) + .map((k) => [k, k === "password" ? passwordHash : req.body[k]]) + ); + + const result = await User.create(body); + + res.json(toSafeUser(result)); } catch (error) { res.json({ error: (error as Error).message }); } @@ -37,11 +62,26 @@ router.post("/", async (req, res) => { router.put("/:id", async (req, res) => { try { - const result = await User.findByIdAndUpdate(req.params.id, req.body, { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + + const update: Record = Object.fromEntries( + USER_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [ + k, + req.body[k], + ]) + ); + + if (req.body.password !== undefined && req.body.password !== "") { + update.password = bcrypt.hashSync(req.body.password, 12); + } + + const result = await User.findByIdAndUpdate(req.params.id, update, { new: true, }); - res.json(result); + res.json(toSafeUser(result)); } catch (error) { res.json({ error: (error as Error).message }); } @@ -49,6 +89,10 @@ router.put("/:id", async (req, res) => { router.delete("/:id", async (req, res) => { try { + if (!isValidObjectId(req.params.id)) { + return res.status(400).json({ error: "Некорректный ID" }); + } + const result = await User.findByIdAndRemove(req.params.id); res.json(result); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index c04736c..e95490e 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,8 +1,8 @@ import { Router } from "express"; -import { parseISO, startOfDay, endOfDay } from "date-fns"; +import { parseISO } from "date-fns"; import Company from "../models/Company.js"; +import CompanyBuild from "../models/CompanyBuild.js"; import ScheduledSession from "../models/ScheduledSession.js"; -import Schedule from "../models/Schedule.js"; import User from "../models/User.js"; import mongoose from "mongoose"; @@ -45,10 +45,19 @@ router.get("/:companyId/builds", async (req, res) => { return res.status(403).json({ error: "Access denied" }); } - const company: any = await Company.findById(req.params.companyId).populate( - "builds" - ); - const { builds } = company; + const companyBuilds = await CompanyBuild.find({ + companyId: req.params.companyId, + }) + .populate("buildId") + .lean(); + + const builds = companyBuilds + .map((cb) => cb.buildId) + .filter(Boolean) + .map((b) => { + const doc = b as unknown as { _id?: { toString: () => string }; id?: string }; + return { ...doc, id: doc.id ?? (doc._id ? doc._id.toString() : undefined) }; + }); res.json(builds); }); diff --git a/server/src/utils/adminUtils.ts b/server/src/utils/adminUtils.ts new file mode 100644 index 0000000..01fa07e --- /dev/null +++ b/server/src/utils/adminUtils.ts @@ -0,0 +1,28 @@ +import mongoose from "mongoose"; + +const ALLOWED_QUERY_KEYS: Record = { + companies: [], + builds: ["companyId", "availableForCompany"], + users: ["companyId"], +}; + +export function sanitizeAdminQuery( + query: Record, + route: "companies" | "builds" | "users" +): Record { + const allowed = ALLOWED_QUERY_KEYS[route]; + const sanitized: Record = {}; + + for (const key of allowed) { + const value = query[key]; + if (value !== undefined && value !== null && value !== "") { + sanitized[key] = value; + } + } + + return sanitized; +} + +export function isValidObjectId(id: string): boolean { + return mongoose.Types.ObjectId.isValid(id) && String(new mongoose.Types.ObjectId(id)) === id; +}