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
+3
View File
@@ -13,6 +13,7 @@ interface Props {
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
onClick?: () => void; onClick?: () => void;
title?: string;
} }
const variantClasses = { const variantClasses = {
@@ -58,11 +59,13 @@ function Button({
disabled, disabled,
loading, loading,
onClick, onClick,
title,
}: Props) { }: Props) {
return ( return (
<button <button
type={type} type={type}
disabled={disabled || loading} disabled={disabled || loading}
title={title}
className={`flex items-center justify-center transition-all outline-none ${ className={`flex items-center justify-center transition-all outline-none ${
variantClasses[variant] variantClasses[variant]
} ${onlyIcon ? onlyIconSizeClasses[size] : sizeClasses[size]} ${ } ${onlyIcon ? onlyIconSizeClasses[size] : sizeClasses[size]} ${
+109 -69
View File
@@ -1,37 +1,34 @@
import { FormEvent, useState } from "react"; import { useEffect, useState } from "react";
import Button from "../Button"; import Button from "../Button";
import Input from "../Input";
import api from "../../utils/api"; import api from "../../utils/api";
import IError from "../../types/IError"; import IError from "../../types/IError";
import IBuild from "../../types/IBuild"; import IBuild from "../../types/IBuild";
import useModalStore from "../../stores/useModalStore"; import useModalStore from "../../stores/useModalStore";
import Select3 from "../Select3"; import SpinnerIcon from "../icons/SpinnerIcon";
interface Props { interface Props {
companyId: string; companyId: string;
} }
function CreateBuildModal({ companyId }: Props) { function CreateBuildModal({ companyId }: Props) {
const [name, setName] = useState<string>(""); const [builds, setBuilds] = useState<IBuild[]>([]);
const [build, setBuild] = useState<string>(""); const [selectedBuilds, setSelectedBuilds] = useState<IBuild[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const { setModal } = useModalStore(); const { setModal } = useModalStore();
function handleSubmit(e: FormEvent) { function toggleBuild(build: IBuild) {
e.preventDefault(); setSelectedBuilds((prev) =>
prev.some((b) => b.id === build.id)
addBuild(); ? prev.filter((b) => b.id !== build.id)
: [...prev, build]
);
} }
async function addBuild() { async function fetchBuilds() {
try { try {
const result: IBuild | IError = await api const result: IBuild[] | IError = await api
.post(`admin/builds`, { .get(`admin/builds?availableForCompany=${companyId}`)
json: {
companyId,
name,
build,
},
})
.json(); .json();
if ("error" in result) { if ("error" in result) {
@@ -39,65 +36,108 @@ function CreateBuildModal({ companyId }: Props) {
return; return;
} }
window.location.reload(); setBuilds(result);
} catch (error) { } catch (error) {
alert((error as Error).message); alert((error as Error).message);
} finally {
setIsLoading(false);
} }
} }
return ( useEffect(() => {
<div className="p-8 space-y-4 bg-white rounded-xl"> fetchBuilds();
<p className="text-xl font-semibold">Создание ЖК</p> }, [companyId]);
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input async function handleAdd() {
required if (selectedBuilds.length === 0) return;
autoFocus
placeholder="Название ЖК" setIsSubmitting(true);
className="w-64" try {
onChange={(value) => setName(value)} for (const build of selectedBuilds) {
/> const result: IBuild | IError = await api
<Select3 .post("admin/builds", {
required json: {
options={[ companyId,
"nksJukovaDev", buildId: build.id,
"Avgust", },
"DNScity", })
"DreamRiva", .json();
"GalleryEkb",
"Ivazowsky", if ("error" in result) {
"IzdanieBrusnika", alert(result.error);
"Kama", return;
"lifeResidence", }
"MasharovDev", }
"MayakPruds",
"MirapolisDev", setModal(null);
"Myatnyi", window.location.reload();
"novatorDev", } catch (error) {
"orientDev", alert((error as Error).message);
"PortovayaDev", } finally {
"Prokshino", setIsSubmitting(false);
"RockCityDev", }
"SevernyPort", }
"Sezar",
"ShipyardSaudiDev", if (isLoading) {
"StroyProject", return (
"StroyProject2", <div className="flex justify-center items-center p-12 bg-white rounded-lg w-[400px]">
"TyoplyeQuDev", <SpinnerIcon />
"upsideTowersDev", </div>
"VoiceInHeart", );
"WillTowers", }
"ZolotoyRog",
"Zoolog", if (builds.length === 0) {
]} return (
onSelect={(option) => setBuild(option)} <div className="p-8 space-y-4 bg-white rounded-lg w-[400px]">
/> <p className="text-xl font-semibold">Добавить ЖК</p>
<div className="flex self-end gap-2"> <p className="text-gray-600">
В базе данных пока нет ЖК. Создайте ЖК через базу данных вручную.
</p>
<div className="flex self-end">
<Button variant="secondary" onClick={() => setModal(null)}> <Button variant="secondary" onClick={() => setModal(null)}>
Отмена Закрыть
</Button> </Button>
<Button type="submit">Сохранить</Button>
</div> </div>
</form> </div>
);
}
return (
<div className="p-8 space-y-4 bg-white rounded-lg w-[400px]">
<p className="text-xl font-semibold">Выбор ЖК</p>
<p className="text-sm text-gray-600">
Выберите один или несколько ЖК для добавления в компанию
</p>
<div className="overflow-y-auto space-y-2 max-h-64">
{builds.map((build) => (
<button
key={build.id}
type="button"
onClick={() => toggleBuild(build)}
className={`w-full p-4 text-left rounded-lg border transition-colors ${
selectedBuilds.some((b) => b.id === build.id)
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
<p className="font-medium">{build.name}</p>
<p className="text-sm text-gray-500">Сборка: {build.build}</p>
</button>
))}
</div>
<div className="flex gap-2 self-end">
<Button variant="secondary" onClick={() => setModal(null)}>
Отмена
</Button>
<Button
onClick={handleAdd}
disabled={selectedBuilds.length === 0 || isSubmitting}
>
{isSubmitting
? "Добавление…"
: `Добавить${selectedBuilds.length > 0 ? ` (${selectedBuilds.length})` : ""}`}
</Button>
</div>
</div> </div>
); );
} }
+36 -1
View File
@@ -13,6 +13,7 @@ import CreateUserModal from "../components/modals/CreateUserModal";
import { Transition } from "react-transition-group"; import { Transition } from "react-transition-group";
import SpinnerIcon from "../components/icons/SpinnerIcon"; import SpinnerIcon from "../components/icons/SpinnerIcon";
import MoreIcon from "../components/icons/MoreIcon"; import MoreIcon from "../components/icons/MoreIcon";
import CloseIcon from "../components/icons/CloseIcon";
import EditUserModal from "../components/modals/EditUserModal"; import EditUserModal from "../components/modals/EditUserModal";
function AdminCompanyPage() { 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(() => { useEffect(() => {
getBuilds(); getBuilds();
getUsers(); getUsers();
@@ -100,7 +124,10 @@ function AdminCompanyPage() {
<div className="grid grid-cols-4 gap-4 pb-8 border-b border-gray-300"> <div className="grid grid-cols-4 gap-4 pb-8 border-b border-gray-300">
{builds && {builds &&
builds.map((build) => ( builds.map((build) => (
<div className="overflow-hidden relative bg-white rounded-xl"> <div
key={build.id}
className="overflow-hidden relative bg-white rounded-xl group"
>
<img <img
src="" src=""
alt="" alt=""
@@ -113,6 +140,14 @@ function AdminCompanyPage() {
<span className="font-semibold">{build.build}</span> <span className="font-semibold">{build.build}</span>
</p> </p>
</div> </div>
<Button
variant="tertiary"
icon={<CloseIcon className="w-5 h-5" />}
onlyIcon
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90 hover:bg-white"
onClick={() => removeBuildFromCompany(build.id)}
title="Убрать из компании"
/>
</div> </div>
))} ))}
</div> </div>
-1
View File
@@ -1,6 +1,5 @@
interface IBuild { interface IBuild {
id: string; id: string;
companyId: string;
build: string; build: string;
name: string; name: string;
} }
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "nodemon --exec node --import=./register.js ./src/index.ts", "dev": "nodemon --exec node --import=./register.js ./src/index.ts",
"build": "npx tsc -p ./", "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": { "dependencies": {
"bcrypt": "^5.1.1", "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 cors from "cors";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import authMiddleware from "./middlewares/auth.js"; import authMiddleware from "./middlewares/auth.js";
import adminOnlyMiddleware from "./middlewares/adminOnly.js";
import registerRoute from "./routes/register.js"; import registerRoute from "./routes/register.js";
import refreshRoute from "./routes/refresh.js"; import refreshRoute from "./routes/refresh.js";
import checkRoute from "./routes/check.js"; import checkRoute from "./routes/check.js";
@@ -46,9 +47,24 @@ app.use("/resetConfirm", resetConfirmRoute);
app.use("/actions", actionsRouter); app.use("/actions", actionsRouter);
app.use("/builds", buildsRouter); app.use("/builds", buildsRouter);
app.use("/scheduledSessions", scheduledSessionsRoute); app.use("/scheduledSessions", scheduledSessionsRoute);
app.use("/admin/companies", adminCompaniesRoute); app.use(
app.use("/admin/builds", adminBuildsRoute); "/admin/companies",
app.use("/admin/users", adminUsersRoute); authMiddleware,
adminOnlyMiddleware,
adminCompaniesRoute
);
app.use(
"/admin/builds",
authMiddleware,
adminOnlyMiddleware,
adminBuildsRoute
);
app.use(
"/admin/users",
authMiddleware,
adminOnlyMiddleware,
adminUsersRoute
);
app.use("/companies", authMiddleware, companiesRouter); app.use("/companies", authMiddleware, companiesRouter);
app.use("/users", authMiddleware, usersRouter); app.use("/users", authMiddleware, usersRouter);
app.use("/changePassword", authMiddleware, changePasswordRoute); 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( const buildSchema = new Schema(
{ {
companyId: {
type: Schema.Types.ObjectId,
required: true,
},
name: { name: {
type: String, type: String,
required: true, required: true,
+2 -2
View File
@@ -46,8 +46,8 @@ companySchema.virtual("users", {
foreignField: "companyId", foreignField: "companyId",
}); });
companySchema.virtual("builds", { companySchema.virtual("companyBuilds", {
ref: "Build", ref: "CompanyBuild",
localField: "_id", localField: "_id",
foreignField: "companyId", 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 { Router } from "express";
import Build from "../../models/Build.js"; import Build from "../../models/Build.js";
import CompanyBuild from "../../models/CompanyBuild.js";
import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js";
const router = Router(); const router = Router();
const BUILD_FIELDS = ["name", "build"];
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
try { 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); res.json(result);
} catch (error) { } catch (error) {
res.json({ error: (error as Error).message }); res.json({ error: (error as Error).message });
@@ -15,6 +48,10 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
try { try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Build.findById(req.params.id); const result = await Build.findById(req.params.id);
res.json(result); res.json(result);
@@ -25,7 +62,27 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
try { 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); res.json(result);
} catch (error) { } catch (error) {
@@ -35,7 +92,18 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
try { 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, new: true,
}); });
@@ -47,6 +115,10 @@ router.put("/:id", async (req, res) => {
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
try { try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Build.findByIdAndRemove(req.params.id); const result = await Build.findByIdAndRemove(req.params.id);
res.json(result); res.json(result);
+67 -3
View File
@@ -1,11 +1,26 @@
import { Router } from "express"; import { Router } from "express";
import Company from "../../models/Company.js"; import Company from "../../models/Company.js";
import CompanyBuild from "../../models/CompanyBuild.js";
import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js";
const router = Router(); const router = Router();
const COMPANY_FIELDS = [
"name",
"sessionLimit",
"avatar",
"phone",
"site",
"email",
"address",
"startTime",
"endTime",
];
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
try { 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); res.json(result);
} catch (error) { } catch (error) {
@@ -15,6 +30,10 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
try { try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Company.findById(req.params.id); const result = await Company.findById(req.params.id);
res.json(result); res.json(result);
@@ -25,7 +44,14 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
try { 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); res.json(result);
} catch (error) { } catch (error) {
@@ -35,7 +61,18 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
try { 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, 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) => { router.delete("/:id", async (req, res) => {
try { try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Company.findByIdAndRemove(req.params.id); const result = await Company.findByIdAndRemove(req.params.id);
res.json(result); res.json(result);
+51 -7
View File
@@ -1,12 +1,23 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { Router } from "express"; import { Router } from "express";
import User from "../../models/User.js"; import User from "../../models/User.js";
import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js";
const router = Router(); 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) => { router.get("/", async (req, res) => {
try { 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); res.json(result);
} catch (error) { } catch (error) {
@@ -16,7 +27,11 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
try { 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); res.json(result);
} catch (error) { } catch (error) {
@@ -26,10 +41,20 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
try { try {
const passwordHash = bcrypt.hashSync(req.body.password, 12); if (!req.body.password || typeof req.body.password !== "string") {
const result = await User.create({ ...req.body, password: passwordHash }); 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) { } catch (error) {
res.json({ error: (error as Error).message }); res.json({ error: (error as Error).message });
} }
@@ -37,11 +62,26 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
try { 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, new: true,
}); });
res.json(result); res.json(toSafeUser(result));
} catch (error) { } catch (error) {
res.json({ error: (error as Error).message }); res.json({ error: (error as Error).message });
} }
@@ -49,6 +89,10 @@ router.put("/:id", async (req, res) => {
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
try { try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await User.findByIdAndRemove(req.params.id); const result = await User.findByIdAndRemove(req.params.id);
res.json(result); res.json(result);
+15 -6
View File
@@ -1,8 +1,8 @@
import { Router } from "express"; import { Router } from "express";
import { parseISO, startOfDay, endOfDay } from "date-fns"; import { parseISO } from "date-fns";
import Company from "../models/Company.js"; import Company from "../models/Company.js";
import CompanyBuild from "../models/CompanyBuild.js";
import ScheduledSession from "../models/ScheduledSession.js"; import ScheduledSession from "../models/ScheduledSession.js";
import Schedule from "../models/Schedule.js";
import User from "../models/User.js"; import User from "../models/User.js";
import mongoose from "mongoose"; import mongoose from "mongoose";
@@ -45,10 +45,19 @@ router.get("/:companyId/builds", async (req, res) => {
return res.status(403).json({ error: "Access denied" }); return res.status(403).json({ error: "Access denied" });
} }
const company: any = await Company.findById(req.params.companyId).populate( const companyBuilds = await CompanyBuild.find({
"builds" companyId: req.params.companyId,
); })
const { builds } = company; .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); 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;
}