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:
+2
-1
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -46,8 +46,8 @@ companySchema.virtual("users", {
|
||||
foreignField: "companyId",
|
||||
});
|
||||
|
||||
companySchema.virtual("builds", {
|
||||
ref: "Build",
|
||||
companySchema.virtual("companyBuilds", {
|
||||
ref: "CompanyBuild",
|
||||
localField: "_id",
|
||||
foreignField: "companyId",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user