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.

This commit is contained in:
2026-03-16 18:59:49 +05:00
parent 1e22853146
commit 9f828473f6
16 changed files with 514 additions and 100 deletions
+2 -1
View File
@@ -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",
@@ -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);
});
+19 -3
View File
@@ -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);
+15
View File
@@ -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;
-4
View File
@@ -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,
+2 -2
View File
@@ -46,8 +46,8 @@ companySchema.virtual("users", {
foreignField: "companyId",
});
companySchema.virtual("builds", {
ref: "Build",
companySchema.virtual("companyBuilds", {
ref: "CompanyBuild",
localField: "_id",
foreignField: "companyId",
});
+25
View File
@@ -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;
+75 -3
View File
@@ -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<string, unknown>;
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);
+67 -3
View File
@@ -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<string, unknown>, "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);
+51 -7
View File
@@ -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<string, unknown> } | null) {
if (!doc) return null;
const json = doc.toJSON ? doc.toJSON() : (doc as Record<string, unknown>);
const { password, resetCode, ...safe } = json as Record<string, unknown>;
return safe;
}
router.get("/", async (req, res) => {
try {
const result = await User.find(req.query);
const filter = sanitizeAdminQuery(req.query as Record<string, unknown>, "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<string, unknown> = 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);
+15 -6
View File
@@ -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);
});
+28
View File
@@ -0,0 +1,28 @@
import mongoose from "mongoose";
const ALLOWED_QUERY_KEYS: Record<string, string[]> = {
companies: [],
builds: ["companyId", "availableForCompany"],
users: ["companyId"],
};
export function sanitizeAdminQuery(
query: Record<string, unknown>,
route: "companies" | "builds" | "users"
): Record<string, unknown> {
const allowed = ALLOWED_QUERY_KEYS[route];
const sanitized: Record<string, unknown> = {};
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;
}