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:
@@ -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 (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
title={title}
|
||||
className={`flex items-center justify-center transition-all outline-none ${
|
||||
variantClasses[variant]
|
||||
} ${onlyIcon ? onlyIconSizeClasses[size] : sizeClasses[size]} ${
|
||||
|
||||
@@ -1,35 +1,64 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "../Button";
|
||||
import Input from "../Input";
|
||||
import api from "../../utils/api";
|
||||
import IError from "../../types/IError";
|
||||
import IBuild from "../../types/IBuild";
|
||||
import useModalStore from "../../stores/useModalStore";
|
||||
import Select3 from "../Select3";
|
||||
import SpinnerIcon from "../icons/SpinnerIcon";
|
||||
|
||||
interface Props {
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
function CreateBuildModal({ companyId }: Props) {
|
||||
const [name, setName] = useState<string>("");
|
||||
const [build, setBuild] = useState<string>("");
|
||||
const [builds, setBuilds] = useState<IBuild[]>([]);
|
||||
const [selectedBuilds, setSelectedBuilds] = useState<IBuild[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
addBuild();
|
||||
function toggleBuild(build: IBuild) {
|
||||
setSelectedBuilds((prev) =>
|
||||
prev.some((b) => b.id === build.id)
|
||||
? prev.filter((b) => b.id !== build.id)
|
||||
: [...prev, build]
|
||||
);
|
||||
}
|
||||
|
||||
async function addBuild() {
|
||||
async function fetchBuilds() {
|
||||
try {
|
||||
const result: IBuild[] | IError = await api
|
||||
.get(`admin/builds?availableForCompany=${companyId}`)
|
||||
.json();
|
||||
|
||||
if ("error" in result) {
|
||||
alert(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setBuilds(result);
|
||||
} catch (error) {
|
||||
alert((error as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuilds();
|
||||
}, [companyId]);
|
||||
|
||||
async function handleAdd() {
|
||||
if (selectedBuilds.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
for (const build of selectedBuilds) {
|
||||
const result: IBuild | IError = await api
|
||||
.post(`admin/builds`, {
|
||||
.post("admin/builds", {
|
||||
json: {
|
||||
companyId,
|
||||
name,
|
||||
build,
|
||||
buildId: build.id,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
@@ -38,66 +67,77 @@ function CreateBuildModal({ companyId }: Props) {
|
||||
alert(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setModal(null);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
alert((error as Error).message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 space-y-4 bg-white rounded-xl">
|
||||
<p className="text-xl font-semibold">Создание ЖК</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
required
|
||||
autoFocus
|
||||
placeholder="Название ЖК"
|
||||
className="w-64"
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
<Select3
|
||||
required
|
||||
options={[
|
||||
"nksJukovaDev",
|
||||
"Avgust",
|
||||
"DNScity",
|
||||
"DreamRiva",
|
||||
"GalleryEkb",
|
||||
"Ivazowsky",
|
||||
"IzdanieBrusnika",
|
||||
"Kama",
|
||||
"lifeResidence",
|
||||
"MasharovDev",
|
||||
"MayakPruds",
|
||||
"MirapolisDev",
|
||||
"Myatnyi",
|
||||
"novatorDev",
|
||||
"orientDev",
|
||||
"PortovayaDev",
|
||||
"Prokshino",
|
||||
"RockCityDev",
|
||||
"SevernyPort",
|
||||
"Sezar",
|
||||
"ShipyardSaudiDev",
|
||||
"StroyProject",
|
||||
"StroyProject2",
|
||||
"TyoplyeQuDev",
|
||||
"upsideTowersDev",
|
||||
"VoiceInHeart",
|
||||
"WillTowers",
|
||||
"ZolotoyRog",
|
||||
"Zoolog",
|
||||
]}
|
||||
onSelect={(option) => setBuild(option)}
|
||||
/>
|
||||
<div className="flex self-end gap-2">
|
||||
<div className="flex justify-center items-center p-12 bg-white rounded-lg w-[400px]">
|
||||
<SpinnerIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (builds.length === 0) {
|
||||
return (
|
||||
<div className="p-8 space-y-4 bg-white rounded-lg w-[400px]">
|
||||
<p className="text-xl font-semibold">Добавить ЖК</p>
|
||||
<p className="text-gray-600">
|
||||
В базе данных пока нет ЖК. Создайте ЖК через базу данных вручную.
|
||||
</p>
|
||||
<div className="flex self-end">
|
||||
<Button variant="secondary" onClick={() => setModal(null)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</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 type="submit">Сохранить</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={selectedBuilds.length === 0 || isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Добавление…"
|
||||
: `Добавить${selectedBuilds.length > 0 ? ` (${selectedBuilds.length})` : ""}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className="grid grid-cols-4 gap-4 pb-8 border-b border-gray-300">
|
||||
{builds &&
|
||||
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
|
||||
src=""
|
||||
alt=""
|
||||
@@ -113,6 +140,14 @@ function AdminCompanyPage() {
|
||||
<span className="font-semibold">{build.build}</span>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
interface IBuild {
|
||||
id: string;
|
||||
companyId: string;
|
||||
build: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
+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