@mashka818/exam-de-template 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/exam-guides/COMMENTS_GUIDE.md +53 -0
- package/exam-guides/EXAM_COMMANDS.txt +47 -0
- package/exam-guides/GUIDE_PAGES.md +529 -0
- package/exam-guides/NPM_PACKAGE.md +206 -0
- package/exam-guides/README.md +40 -0
- package/exam-guides/RESPONSIVE.md +224 -0
- package/exam-guides/TECH_STACK.txt +142 -0
- package/exam-guides/THEME_BANQUETAM_NET.md +106 -0
- package/exam-guides/commented-code/README.txt +5 -0
- package/exam-guides/commented-code/client/index.html +14 -0
- package/exam-guides/commented-code/client/package-lock.json +2298 -0
- package/exam-guides/commented-code/client/package.json +21 -0
- package/exam-guides/commented-code/client/public/images/README.txt +26 -0
- package/exam-guides/commented-code/client/public/images/about-cleaning.svg +4 -0
- package/exam-guides/commented-code/client/public/images/admin-banner.svg +1 -0
- package/exam-guides/commented-code/client/public/images/empty-requests.svg +1 -0
- package/exam-guides/commented-code/client/public/images/footer-photo-1.svg +4 -0
- package/exam-guides/commented-code/client/public/images/footer-photo-2.svg +4 -0
- package/exam-guides/commented-code/client/public/images/footer-photo-3.svg +4 -0
- package/exam-guides/commented-code/client/public/images/home-hero.svg +4 -0
- package/exam-guides/commented-code/client/public/images/login-banner.svg +1 -0
- package/exam-guides/commented-code/client/public/images/logo.svg +4 -0
- package/exam-guides/commented-code/client/public/images/new-request-banner.svg +1 -0
- package/exam-guides/commented-code/client/public/images/register-banner.svg +1 -0
- package/exam-guides/commented-code/client/public/images/requests-banner.svg +1 -0
- package/exam-guides/commented-code/client/public/images/slide-1.svg +6 -0
- package/exam-guides/commented-code/client/public/images/slide-2.svg +5 -0
- package/exam-guides/commented-code/client/public/images/slide-3.svg +5 -0
- package/exam-guides/commented-code/client/src/App.jsx +72 -0
- package/exam-guides/commented-code/client/src/api.js +71 -0
- package/exam-guides/commented-code/client/src/components/FormField.jsx +25 -0
- package/exam-guides/commented-code/client/src/components/Layout.jsx +83 -0
- package/exam-guides/commented-code/client/src/components/PageImage.jsx +38 -0
- package/exam-guides/commented-code/client/src/components/ProtectedRoute.jsx +35 -0
- package/exam-guides/commented-code/client/src/components/UserNav.jsx +33 -0
- package/exam-guides/commented-code/client/src/components/landing/HeroSlider.jsx +103 -0
- package/exam-guides/commented-code/client/src/components/landing/LandingLayout.jsx +76 -0
- package/exam-guides/commented-code/client/src/components/landing/SiteFooter.jsx +74 -0
- package/exam-guides/commented-code/client/src/config/images.js +104 -0
- package/exam-guides/commented-code/client/src/constants/services.js +19 -0
- package/exam-guides/commented-code/client/src/context/AuthContext.jsx +72 -0
- package/exam-guides/commented-code/client/src/index.css +73 -0
- package/exam-guides/commented-code/client/src/main.jsx +28 -0
- package/exam-guides/commented-code/client/src/pages/AdminPage.jsx +151 -0
- package/exam-guides/commented-code/client/src/pages/LandingPage.jsx +131 -0
- package/exam-guides/commented-code/client/src/pages/LoginPage.jsx +81 -0
- package/exam-guides/commented-code/client/src/pages/RegisterPage.jsx +117 -0
- package/exam-guides/commented-code/client/src/pages/RequestFormPage.jsx +196 -0
- package/exam-guides/commented-code/client/src/pages/RequestsPage.jsx +112 -0
- package/exam-guides/commented-code/client/src/utils/validation.js +71 -0
- package/exam-guides/commented-code/client/vite.config.js +31 -0
- package/exam-guides/commented-code/server/db/init.js +67 -0
- package/exam-guides/commented-code/server/db/pool.js +23 -0
- package/exam-guides/commented-code/server/db/schema.sql +53 -0
- package/exam-guides/commented-code/server/db/seed.sql +15 -0
- package/exam-guides/commented-code/server/index.js +45 -0
- package/exam-guides/commented-code/server/middleware/auth.js +38 -0
- package/exam-guides/commented-code/server/package-lock.json +1084 -0
- package/exam-guides/commented-code/server/package.json +17 -0
- package/exam-guides/commented-code/server/routes/admin.js +96 -0
- package/exam-guides/commented-code/server/routes/auth.js +128 -0
- package/exam-guides/commented-code/server/routes/requests.js +115 -0
- package/exam-guides/commented-code/server/routes/services.js +31 -0
- package/exam-guides/commented-code/server/utils/validation.js +81 -0
- package/exam-guides/exam-starter/README.txt +22 -0
- package/exam-guides/exam-starter/package.json +13 -0
- package/exam-guides//320/243/320/224/320/220/320/233/320/230/320/242/320/254-/320/237/320/225/320/240/320/225/320/224-/320/241/320/224/320/220/320/247/320/225/320/231.txt +9 -0
- package/exam-project/README.md +16 -0
- package/exam-project/client/index.html +14 -0
- package/exam-project/client/package-lock.json +2298 -0
- package/exam-project/client/package.json +21 -0
- package/exam-project/client/public/images/README.txt +26 -0
- package/exam-project/client/public/images/about-cleaning.svg +4 -0
- package/exam-project/client/public/images/admin-banner.svg +1 -0
- package/exam-project/client/public/images/empty-requests.svg +1 -0
- package/exam-project/client/public/images/footer-photo-1.svg +4 -0
- package/exam-project/client/public/images/footer-photo-2.svg +4 -0
- package/exam-project/client/public/images/footer-photo-3.svg +4 -0
- package/exam-project/client/public/images/home-hero.svg +4 -0
- package/exam-project/client/public/images/login-banner.svg +1 -0
- package/exam-project/client/public/images/logo.svg +4 -0
- package/exam-project/client/public/images/new-request-banner.svg +1 -0
- package/exam-project/client/public/images/register-banner.svg +1 -0
- package/exam-project/client/public/images/requests-banner.svg +1 -0
- package/exam-project/client/public/images/slide-1.svg +6 -0
- package/exam-project/client/public/images/slide-2.svg +5 -0
- package/exam-project/client/public/images/slide-3.svg +5 -0
- package/exam-project/client/src/App.jsx +52 -0
- package/exam-project/client/src/api.js +50 -0
- package/exam-project/client/src/components/FormField.jsx +11 -0
- package/exam-project/client/src/components/Layout.jsx +61 -0
- package/exam-project/client/src/components/PageImage.jsx +22 -0
- package/exam-project/client/src/components/ProtectedRoute.jsx +20 -0
- package/exam-project/client/src/components/UserNav.jsx +20 -0
- package/exam-project/client/src/components/landing/HeroSlider.jsx +89 -0
- package/exam-project/client/src/components/landing/LandingLayout.jsx +61 -0
- package/exam-project/client/src/components/landing/SiteFooter.jsx +61 -0
- package/exam-project/client/src/config/images.js +83 -0
- package/exam-project/client/src/constants/services.js +7 -0
- package/exam-project/client/src/context/AuthContext.jsx +55 -0
- package/exam-project/client/src/index.css +54 -0
- package/exam-project/client/src/main.jsx +17 -0
- package/exam-project/client/src/pages/AdminPage.jsx +128 -0
- package/exam-project/client/src/pages/LandingPage.jsx +115 -0
- package/exam-project/client/src/pages/LoginPage.jsx +63 -0
- package/exam-project/client/src/pages/RegisterPage.jsx +97 -0
- package/exam-project/client/src/pages/RequestFormPage.jsx +178 -0
- package/exam-project/client/src/pages/RequestsPage.jsx +94 -0
- package/exam-project/client/src/utils/validation.js +54 -0
- package/exam-project/client/vite.config.js +17 -0
- package/exam-project/package.json +18 -0
- package/exam-project/scripts/init-project.js +86 -0
- package/exam-project/scripts/prepack.js +27 -0
- package/exam-project/scripts/unpack-template.js +105 -0
- package/exam-project/server/db/init.js +50 -0
- package/exam-project/server/db/pool.js +11 -0
- package/exam-project/server/db/schema.sql +41 -0
- package/exam-project/server/db/seed.sql +12 -0
- package/exam-project/server/index.js +29 -0
- package/exam-project/server/middleware/auth.js +24 -0
- package/exam-project/server/package-lock.json +1084 -0
- package/exam-project/server/package.json +17 -0
- package/exam-project/server/routes/admin.js +76 -0
- package/exam-project/server/routes/auth.js +109 -0
- package/exam-project/server/routes/requests.js +99 -0
- package/exam-project/server/routes/services.js +18 -0
- package/exam-project/server/utils/validation.js +63 -0
- package/package.json +25 -0
- package/scripts/init-project.js +86 -0
- package/scripts/prepack.js +27 -0
- package/scripts/unpack-template.js +105 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "exam-template-server",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "node --watch index.js",
|
|
6
|
+
"start": "node index.js",
|
|
7
|
+
"db:init": "node db/init.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"bcryptjs": "^3.0.2",
|
|
11
|
+
"cors": "^2.8.5",
|
|
12
|
+
"dotenv": "^16.5.0",
|
|
13
|
+
"express": "^5.1.0",
|
|
14
|
+
"jsonwebtoken": "^9.0.2",
|
|
15
|
+
"pg": "^8.16.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { pool } from '../db/pool.js';
|
|
4
|
+
import { authRequired, adminRequired } from '../middleware/auth.js';
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
const ALLOWED_STATUSES = ['in_progress', 'completed', 'cancelled'];
|
|
9
|
+
|
|
10
|
+
// GET /api/admin/requests — все заявки для панели администратора
|
|
11
|
+
router.get('/requests', authRequired, adminRequired, async (_req, res) => {
|
|
12
|
+
try {
|
|
13
|
+
const { rows } = await pool.query(
|
|
14
|
+
`SELECT r.*, st.name AS service_name, u.full_name AS user_full_name,
|
|
15
|
+
u.phone AS user_phone, u.email AS user_email
|
|
16
|
+
FROM requests r
|
|
17
|
+
LEFT JOIN service_types st ON st.id = r.service_type_id
|
|
18
|
+
JOIN users u ON u.id = r.user_id
|
|
19
|
+
ORDER BY r.created_at DESC`
|
|
20
|
+
);
|
|
21
|
+
res.json(
|
|
22
|
+
rows.map((row) => ({
|
|
23
|
+
id: row.id,
|
|
24
|
+
userFullName: row.user_full_name,
|
|
25
|
+
userPhone: row.user_phone,
|
|
26
|
+
userEmail: row.user_email,
|
|
27
|
+
address: row.address,
|
|
28
|
+
contactPhone: row.contact_phone,
|
|
29
|
+
serviceName: row.service_name || row.custom_service,
|
|
30
|
+
scheduledAt: row.scheduled_at,
|
|
31
|
+
paymentType: row.payment_type,
|
|
32
|
+
paymentLabel: row.payment_type === 'cash' ? 'Наличные' : 'Банковская карта',
|
|
33
|
+
status: row.status,
|
|
34
|
+
statusLabel:
|
|
35
|
+
{ new: 'Новая', in_progress: 'В работе', completed: 'Выполнено', cancelled: 'Отменено' }[
|
|
36
|
+
row.status
|
|
37
|
+
] || row.status,
|
|
38
|
+
cancelReason: row.cancel_reason,
|
|
39
|
+
createdAt: row.created_at,
|
|
40
|
+
}))
|
|
41
|
+
);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(err);
|
|
44
|
+
res.status(500).json({ message: 'Ошибка сервера' });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// PATCH /api/admin/requests/:id/status — смена статуса админом
|
|
49
|
+
router.patch('/requests/:id/status', authRequired, adminRequired, async (req, res) => {
|
|
50
|
+
const { status, cancelReason } = req.body;
|
|
51
|
+
const id = Number(req.params.id);
|
|
52
|
+
|
|
53
|
+
if (!ALLOWED_STATUSES.includes(status)) {
|
|
54
|
+
return res.status(400).json({ message: 'Недопустимый статус' });
|
|
55
|
+
}
|
|
56
|
+
if (status === 'cancelled' && !cancelReason?.trim()) {
|
|
57
|
+
return res.status(400).json({ message: 'Укажите причину отмены' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const { rows } = await pool.query(
|
|
62
|
+
`UPDATE requests
|
|
63
|
+
SET status = $1, cancel_reason = $2
|
|
64
|
+
WHERE id = $3
|
|
65
|
+
RETURNING *`,
|
|
66
|
+
[status, status === 'cancelled' ? cancelReason.trim() : null, id]
|
|
67
|
+
);
|
|
68
|
+
if (!rows[0]) return res.status(404).json({ message: 'Заявка не найдена' });
|
|
69
|
+
res.json({ ok: true });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(err);
|
|
72
|
+
res.status(500).json({ message: 'Ошибка сервера' });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export default router;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import bcrypt from 'bcryptjs';
|
|
4
|
+
import jwt from 'jsonwebtoken';
|
|
5
|
+
import { pool } from '../db/pool.js';
|
|
6
|
+
import { validateRegistration } from '../utils/validation.js';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
// POST /api/auth/register — регистрация заказчика
|
|
11
|
+
router.post('/register', async (req, res) => {
|
|
12
|
+
const { login, password, fullName, phone, email } = req.body;
|
|
13
|
+
const errors = validateRegistration({ login, password, fullName, phone, email });
|
|
14
|
+
if (Object.keys(errors).length) {
|
|
15
|
+
return res.status(400).json({ errors });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const exists = await pool.query('SELECT id FROM users WHERE login = $1', [login.trim()]);
|
|
20
|
+
if (exists.rows.length) {
|
|
21
|
+
return res.status(400).json({ errors: { login: 'Логин уже занят' } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const passwordHash = await bcrypt.hash(password, 10);
|
|
25
|
+
const result = await pool.query(
|
|
26
|
+
`INSERT INTO users (login, password_hash, full_name, phone, email)
|
|
27
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
28
|
+
RETURNING id, login, full_name, phone, email, role`,
|
|
29
|
+
[login.trim(), passwordHash, fullName.trim(), phone.trim(), email.trim()]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const user = result.rows[0];
|
|
33
|
+
const token = signToken(user);
|
|
34
|
+
res.status(201).json({ user: mapUser(user), token });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(err);
|
|
37
|
+
res.status(500).json({ message: 'Ошибка сервера' });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// POST /api/auth/login — вход (и обычный пользователь, и adminka)
|
|
42
|
+
router.post('/login', async (req, res) => {
|
|
43
|
+
const { login, password } = req.body;
|
|
44
|
+
if (!login?.trim() || !password) {
|
|
45
|
+
return res.status(400).json({ message: 'Введите логин и пароль' });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const result = await pool.query(
|
|
50
|
+
'SELECT id, login, password_hash, full_name, phone, email, role FROM users WHERE login = $1',
|
|
51
|
+
[login.trim()]
|
|
52
|
+
);
|
|
53
|
+
const user = result.rows[0];
|
|
54
|
+
if (!user) {
|
|
55
|
+
return res.status(401).json({ message: 'Неверный логин или пароль' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ok = await bcrypt.compare(password, user.password_hash);
|
|
59
|
+
if (!ok) {
|
|
60
|
+
return res.status(401).json({ message: 'Неверный логин или пароль' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const token = signToken(user);
|
|
64
|
+
res.json({ user: mapUser(user), token });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error(err);
|
|
67
|
+
res.status(500).json({ message: 'Ошибка сервера' });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// GET /api/auth/me — текущий пользователь по токену
|
|
72
|
+
router.get('/me', async (req, res) => {
|
|
73
|
+
const header = req.headers.authorization;
|
|
74
|
+
if (!header?.startsWith('Bearer ')) {
|
|
75
|
+
return res.status(401).json({ message: 'Не авторизован' });
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
|
|
79
|
+
const result = await pool.query(
|
|
80
|
+
'SELECT id, login, full_name, phone, email, role FROM users WHERE id = $1',
|
|
81
|
+
[payload.id]
|
|
82
|
+
);
|
|
83
|
+
if (!result.rows[0]) return res.status(401).json({ message: 'Пользователь не найден' });
|
|
84
|
+
res.json({ user: mapUser(result.rows[0]) });
|
|
85
|
+
} catch {
|
|
86
|
+
res.status(401).json({ message: 'Недействительный токен' });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function signToken(user) {
|
|
91
|
+
return jwt.sign(
|
|
92
|
+
{ id: user.id, login: user.login, role: user.role },
|
|
93
|
+
process.env.JWT_SECRET,
|
|
94
|
+
{ expiresIn: '24h' }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function mapUser(row) {
|
|
99
|
+
return {
|
|
100
|
+
id: row.id,
|
|
101
|
+
login: row.login,
|
|
102
|
+
fullName: row.full_name,
|
|
103
|
+
phone: row.phone,
|
|
104
|
+
email: row.email,
|
|
105
|
+
role: row.role,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default router;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { pool } from '../db/pool.js';
|
|
4
|
+
import { authRequired } from '../middleware/auth.js';
|
|
5
|
+
import { validateRequest } from '../utils/validation.js';
|
|
6
|
+
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
const STATUS_LABELS = {
|
|
10
|
+
new: 'Новая',
|
|
11
|
+
in_progress: 'В работе',
|
|
12
|
+
completed: 'Выполнено',
|
|
13
|
+
cancelled: 'Отменено',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// GET /api/requests/mine — история заявок текущего пользователя
|
|
17
|
+
router.get('/mine', authRequired, async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const { rows } = await pool.query(
|
|
20
|
+
`SELECT r.*, st.name AS service_name, u.full_name AS user_full_name
|
|
21
|
+
FROM requests r
|
|
22
|
+
LEFT JOIN service_types st ON st.id = r.service_type_id
|
|
23
|
+
JOIN users u ON u.id = r.user_id
|
|
24
|
+
WHERE r.user_id = $1
|
|
25
|
+
ORDER BY r.created_at DESC`,
|
|
26
|
+
[req.user.id]
|
|
27
|
+
);
|
|
28
|
+
res.json(rows.map(mapRequest));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error(err);
|
|
31
|
+
res.status(500).json({ message: 'Ошибка сервера' });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// POST /api/requests — новая заявка
|
|
36
|
+
router.post('/', authRequired, async (req, res) => {
|
|
37
|
+
if (req.user.role === 'admin') {
|
|
38
|
+
return res.status(403).json({ message: 'Администратор не создаёт заявки через этот endpoint' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const body = {
|
|
42
|
+
address: req.body.address,
|
|
43
|
+
contactPhone: req.body.contactPhone,
|
|
44
|
+
scheduledAt: req.body.scheduledAt,
|
|
45
|
+
paymentType: req.body.paymentType,
|
|
46
|
+
serviceTypeId: req.body.serviceTypeId,
|
|
47
|
+
isCustomService: req.body.isCustomService,
|
|
48
|
+
customService: req.body.customService,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const errors = validateRequest(body);
|
|
52
|
+
if (Object.keys(errors).length) {
|
|
53
|
+
return res.status(400).json({ errors });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const { rows } = await pool.query(
|
|
58
|
+
`INSERT INTO requests (
|
|
59
|
+
user_id, address, contact_phone, service_type_id, custom_service,
|
|
60
|
+
scheduled_at, payment_type, status
|
|
61
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'new')
|
|
62
|
+
RETURNING *`,
|
|
63
|
+
[
|
|
64
|
+
req.user.id,
|
|
65
|
+
body.address.trim(),
|
|
66
|
+
body.contactPhone.trim(),
|
|
67
|
+
body.isCustomService ? null : Number(body.serviceTypeId),
|
|
68
|
+
body.isCustomService ? body.customService.trim() : null,
|
|
69
|
+
body.scheduledAt,
|
|
70
|
+
body.paymentType,
|
|
71
|
+
]
|
|
72
|
+
);
|
|
73
|
+
res.status(201).json(mapRequest(rows[0]));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(err);
|
|
76
|
+
res.status(500).json({ message: 'Ошибка сервера' });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function mapRequest(row) {
|
|
81
|
+
return {
|
|
82
|
+
id: row.id,
|
|
83
|
+
userId: row.user_id,
|
|
84
|
+
userFullName: row.user_full_name,
|
|
85
|
+
address: row.address,
|
|
86
|
+
contactPhone: row.contact_phone,
|
|
87
|
+
serviceName: row.service_name || row.custom_service,
|
|
88
|
+
customService: row.custom_service,
|
|
89
|
+
scheduledAt: row.scheduled_at,
|
|
90
|
+
paymentType: row.payment_type,
|
|
91
|
+
paymentLabel: row.payment_type === 'cash' ? 'Наличные' : 'Банковская карта',
|
|
92
|
+
status: row.status,
|
|
93
|
+
statusLabel: STATUS_LABELS[row.status] || row.status,
|
|
94
|
+
cancelReason: row.cancel_reason,
|
|
95
|
+
createdAt: row.created_at,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default router;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { pool } from '../db/pool.js';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
// GET /api/services — список видов услуг для select на форме заявки
|
|
8
|
+
router.get('/', async (_req, res) => {
|
|
9
|
+
try {
|
|
10
|
+
const { rows } = await pool.query('SELECT id, name FROM service_types ORDER BY id');
|
|
11
|
+
res.json(rows);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(err);
|
|
14
|
+
res.status(500).json({ message: 'Ошибка сервера' });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default router;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
/** Формат телефона из задания — синхронизировать с formatPhoneInput на фронте */
|
|
4
|
+
export const PHONE_REGEX = /^\+7\(\d{3}\)-\d{3}-\d{2}-\d{2}$/;
|
|
5
|
+
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
export const FIO_REGEX = /^[А-Яа-яЁё\s-]+$/;
|
|
7
|
+
|
|
8
|
+
/** POST /api/auth/register */
|
|
9
|
+
export function validateRegistration({ login, password, fullName, phone, email }) {
|
|
10
|
+
const errors = {};
|
|
11
|
+
|
|
12
|
+
if (!login?.trim()) errors.login = 'Логин обязателен';
|
|
13
|
+
else if (login.trim().length < 3) errors.login = 'Логин не короче 3 символов';
|
|
14
|
+
|
|
15
|
+
if (!password) errors.password = 'Пароль обязателен';
|
|
16
|
+
else if (password.length < 6) errors.password = 'Пароль не менее 6 символов';
|
|
17
|
+
|
|
18
|
+
if (!fullName?.trim()) errors.fullName = 'ФИО обязательно';
|
|
19
|
+
else if (!FIO_REGEX.test(fullName.trim())) errors.fullName = 'ФИО: только кириллица и пробелы';
|
|
20
|
+
|
|
21
|
+
if (!phone?.trim()) errors.phone = 'Телефон обязателен';
|
|
22
|
+
else if (!PHONE_REGEX.test(phone.trim())) errors.phone = 'Формат: +7(XXX)-XXX-XX-XX';
|
|
23
|
+
|
|
24
|
+
if (!email?.trim()) errors.email = 'Email обязателен';
|
|
25
|
+
else if (!EMAIL_REGEX.test(email.trim())) errors.email = 'Некорректный email';
|
|
26
|
+
|
|
27
|
+
return errors;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** POST /api/requests */
|
|
31
|
+
export function validateRequest(body) {
|
|
32
|
+
const errors = {};
|
|
33
|
+
const {
|
|
34
|
+
address,
|
|
35
|
+
contactPhone,
|
|
36
|
+
scheduledAt,
|
|
37
|
+
paymentType,
|
|
38
|
+
serviceTypeId,
|
|
39
|
+
isCustomService,
|
|
40
|
+
customService,
|
|
41
|
+
} = body;
|
|
42
|
+
|
|
43
|
+
if (!address?.trim()) errors.address = 'Адрес обязателен';
|
|
44
|
+
if (!contactPhone?.trim()) errors.contactPhone = 'Контактный телефон обязателен';
|
|
45
|
+
else if (!PHONE_REGEX.test(contactPhone.trim()))
|
|
46
|
+
errors.contactPhone = 'Формат: +7(XXX)-XXX-XX-XX';
|
|
47
|
+
|
|
48
|
+
if (!scheduledAt) errors.scheduledAt = 'Дата и время обязательны';
|
|
49
|
+
else if (new Date(scheduledAt) <= new Date())
|
|
50
|
+
errors.scheduledAt = 'Дата должна быть в будущем';
|
|
51
|
+
|
|
52
|
+
if (!paymentType) errors.paymentType = 'Укажите способ оплаты';
|
|
53
|
+
else if (!['cash', 'card'].includes(paymentType))
|
|
54
|
+
errors.paymentType = 'Некорректный способ оплаты';
|
|
55
|
+
|
|
56
|
+
if (isCustomService) {
|
|
57
|
+
if (!customService?.trim()) errors.customService = 'Опишите иную услугу';
|
|
58
|
+
} else if (!serviceTypeId) {
|
|
59
|
+
errors.serviceTypeId = 'Выберите вид услуги';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return errors;
|
|
63
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mashka818/exam-de-template",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Шаблон ДЭ: exam-project + exam-guides. Экзамен: npx @mashka818/exam-de-template init",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"exam-de-init": "./scripts/init-project.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"exam-project",
|
|
15
|
+
"exam-guides",
|
|
16
|
+
"scripts"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"demo-exam",
|
|
20
|
+
"de",
|
|
21
|
+
"react",
|
|
22
|
+
"express",
|
|
23
|
+
"postgresql"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Экзамен: создать exam-project + exam-guides в текущей папке.
|
|
4
|
+
* npx @mashka818/exam-de-template@1.0.0 init
|
|
5
|
+
*/
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
13
|
+
|
|
14
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.pack-tmp']);
|
|
15
|
+
|
|
16
|
+
function copyTree(src, dest) {
|
|
17
|
+
if (!fs.existsSync(src)) return;
|
|
18
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
19
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
20
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
21
|
+
const s = path.join(src, entry.name);
|
|
22
|
+
const d = path.join(dest, entry.name);
|
|
23
|
+
if (entry.isDirectory()) copyTree(s, d);
|
|
24
|
+
else {
|
|
25
|
+
if (entry.name === '.env' && s.replace(/\\/g, '/').includes('/server/')) continue;
|
|
26
|
+
fs.mkdirSync(path.dirname(d), { recursive: true });
|
|
27
|
+
fs.copyFileSync(s, d);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const examRoot = process.argv.includes('--here')
|
|
33
|
+
? process.cwd()
|
|
34
|
+
: process.argv[2]
|
|
35
|
+
? path.resolve(process.argv[2])
|
|
36
|
+
: process.cwd();
|
|
37
|
+
|
|
38
|
+
const skipInstall = process.argv.includes('--no-install');
|
|
39
|
+
|
|
40
|
+
const srcProject = path.join(PACKAGE_ROOT, 'exam-project');
|
|
41
|
+
const srcGuides = path.join(PACKAGE_ROOT, 'exam-guides');
|
|
42
|
+
const destProject = path.join(examRoot, 'exam-project');
|
|
43
|
+
const destGuides = path.join(examRoot, 'exam-guides');
|
|
44
|
+
|
|
45
|
+
console.log('\n=== exam-de-init ===\n');
|
|
46
|
+
console.log(`Папка экзамена: ${examRoot}\n`);
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(srcProject)) {
|
|
49
|
+
console.error('В пакете нет exam-project/. Обновите npm publish.');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (fs.existsSync(destProject) && !process.argv.includes('--force')) {
|
|
54
|
+
console.log('Уже есть exam-project/. Добавьте --force для перезаписи.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('Копирую exam-project/ …');
|
|
59
|
+
if (fs.existsSync(destProject)) fs.rmSync(destProject, { recursive: true, force: true });
|
|
60
|
+
copyTree(srcProject, destProject);
|
|
61
|
+
|
|
62
|
+
if (fs.existsSync(srcGuides)) {
|
|
63
|
+
console.log('Копирую exam-guides/ …');
|
|
64
|
+
if (fs.existsSync(destGuides)) fs.rmSync(destGuides, { recursive: true, force: true });
|
|
65
|
+
copyTree(srcGuides, destGuides);
|
|
66
|
+
} else {
|
|
67
|
+
console.warn('В пакете нет exam-guides/ — только чистый проект.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (skipInstall) {
|
|
71
|
+
console.log('\nГотово (--no-install).\n');
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log('\nУстановка зависимостей в exam-project/ …\n');
|
|
76
|
+
execSync('npm install', { cwd: destProject, stdio: 'inherit' });
|
|
77
|
+
execSync('npm run install:all', { cwd: destProject, stdio: 'inherit' });
|
|
78
|
+
|
|
79
|
+
console.log('\n=== Готово ===\n');
|
|
80
|
+
console.log(' exam-project/ — правите и запускаете здесь');
|
|
81
|
+
console.log(' exam-guides/ — шпоры; удалите перед сдачей\n');
|
|
82
|
+
console.log('Дальше:');
|
|
83
|
+
console.log(' cd exam-project');
|
|
84
|
+
console.log(' copy server\\.env.example server\\.env');
|
|
85
|
+
console.log(' npm run db:init');
|
|
86
|
+
console.log(' npm run dev\n');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка перед npm pack — в архив не должны попасть node_modules и .env
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
|
|
10
|
+
const bad = [];
|
|
11
|
+
|
|
12
|
+
for (const sub of ['node_modules', 'client/node_modules', 'server/node_modules', 'client/dist']) {
|
|
13
|
+
if (fs.existsSync(path.join(root, sub))) {
|
|
14
|
+
bad.push(sub);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (fs.existsSync(path.join(root, 'server/.env'))) {
|
|
19
|
+
console.warn('prepack: server/.env есть локально — в .npmignore, в архив не попадёт.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (bad.length) {
|
|
23
|
+
console.warn('prepack: в проекте есть (в .npmignore, в tgz не войдут):');
|
|
24
|
+
bad.forEach((b) => console.warn(` - ${b}`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log('prepack: ok — запускайте npm pack');
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Распаковка шаблона из npm-пакета в папку проекта (экзамен / новый ПК).
|
|
4
|
+
* Вызов: npx exam-de-unpack
|
|
5
|
+
* npx exam-de-unpack C:\DE\my-project
|
|
6
|
+
* npx exam-de-unpack --here
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
14
|
+
|
|
15
|
+
const SKIP_DIRS = new Set([
|
|
16
|
+
'node_modules',
|
|
17
|
+
'.git',
|
|
18
|
+
'dist',
|
|
19
|
+
'exam-starter',
|
|
20
|
+
'design',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const SKIP_ROOT_FILES = new Set([
|
|
24
|
+
'exam-template-moy-ne-sam-1.0.0.tgz',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function shouldSkipDir(name) {
|
|
28
|
+
return SKIP_DIRS.has(name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shouldSkipFile(relPath) {
|
|
32
|
+
const base = path.basename(relPath);
|
|
33
|
+
if (SKIP_ROOT_FILES.has(base)) return true;
|
|
34
|
+
if (base.endsWith('.tgz')) return true;
|
|
35
|
+
if (relPath.replace(/\\/g, '/').includes('server/.env')) return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseTarget() {
|
|
40
|
+
const args = process.argv.slice(2).filter((a) => !a.startsWith('-'));
|
|
41
|
+
if (process.argv.includes('--here') || args.length === 0) {
|
|
42
|
+
return process.cwd();
|
|
43
|
+
}
|
|
44
|
+
return path.resolve(args[0]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function copyRecursive(srcDir, destDir, relative = '') {
|
|
48
|
+
if (!fs.existsSync(srcDir)) return;
|
|
49
|
+
|
|
50
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
51
|
+
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
|
54
|
+
const src = path.join(srcDir, entry.name);
|
|
55
|
+
const dest = path.join(destDir, entry.name);
|
|
56
|
+
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
if (shouldSkipDir(entry.name)) continue;
|
|
59
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
60
|
+
copyRecursive(src, dest, rel);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (shouldSkipFile(rel)) continue;
|
|
65
|
+
|
|
66
|
+
const destEnv = path.join(destDir, 'server', '.env');
|
|
67
|
+
if (rel === 'server/.env' && fs.existsSync(path.join(destDir, 'server', '.env'))) {
|
|
68
|
+
console.log(' пропуск (уже есть): server/.env');
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
73
|
+
fs.copyFileSync(src, dest);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const target = parseTarget();
|
|
78
|
+
const force = process.argv.includes('--force');
|
|
79
|
+
|
|
80
|
+
console.log('');
|
|
81
|
+
console.log('=== exam-de-unpack: шаблон ДЭ ===');
|
|
82
|
+
console.log(`Из: ${PACKAGE_ROOT}`);
|
|
83
|
+
console.log(`В: ${target}`);
|
|
84
|
+
console.log('');
|
|
85
|
+
|
|
86
|
+
if (!fs.existsSync(target)) {
|
|
87
|
+
fs.mkdirSync(target, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const marker = path.join(target, 'client', 'src', 'App.jsx');
|
|
91
|
+
if (fs.existsSync(marker) && !force) {
|
|
92
|
+
console.log('В папке уже есть шаблон (client/src/App.jsx).');
|
|
93
|
+
console.log('Чтобы перезаписать: npx exam-de-unpack --force');
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
copyRecursive(PACKAGE_ROOT, target);
|
|
98
|
+
|
|
99
|
+
console.log('Готово. Дальше в этой папке:');
|
|
100
|
+
console.log(' npm install');
|
|
101
|
+
console.log(' npm run install:all');
|
|
102
|
+
console.log(' настроить server/.env');
|
|
103
|
+
console.log(' npm run db:init');
|
|
104
|
+
console.log(' npm run dev');
|
|
105
|
+
console.log('');
|