base commit

This commit is contained in:
@kirill.komarov
2026-04-28 21:36:30 +05:00
parent 55480d4aa5
commit 2148fd7a12
24 changed files with 1578 additions and 121 deletions
+206 -4
View File
@@ -7,15 +7,17 @@
"": {
"name": "server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@prisma/client": "^5.22.0",
"@fastify/jwt": "^10.0.0",
"@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"fastify": "^5.8.5"
"fastify": "^5.8.5",
"nodemailer": "^8.0.7"
},
"devDependencies": {
"prisma": "^5.22.0"
"prisma": "5.22.0"
}
},
"node_modules/@fastify/ajv-compiler": {
@@ -110,6 +112,29 @@
],
"license": "MIT"
},
"node_modules/@fastify/jwt": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
"integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.2.0",
"@lukeed/ms": "^2.0.2",
"fast-jwt": "^6.0.2",
"fastify-plugin": "^5.0.1",
"steed": "^1.1.3"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -149,6 +174,15 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -262,6 +296,18 @@
}
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -291,6 +337,21 @@
"fastq": "^1.17.1"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -325,6 +386,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@@ -361,6 +431,22 @@
"rfdc": "^1.2.0"
}
},
"node_modules/fast-jwt": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.3.tgz",
"integrity": "sha512-A8NqEOEO7tvUHx+ZXxc+qcbCbZXrBEvANATx5MqppygvJh1F+cHGtDuRuJjehsXDplbfCodNOFcyeWwB+ZIRyw==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0",
"safe-regex2": "^5.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
@@ -386,6 +472,18 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastfall": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
"license": "MIT",
"dependencies": {
"reusify": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fastify": {
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
@@ -435,6 +533,16 @@
],
"license": "MIT"
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -444,6 +552,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fastseries": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.0",
"xtend": "^4.0.0"
}
},
"node_modules/find-my-way": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
@@ -473,6 +591,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
@@ -544,6 +668,36 @@
],
"license": "MIT"
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -676,6 +830,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
@@ -707,6 +881,12 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -759,6 +939,19 @@
"node": ">= 10.x"
}
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
"license": "MIT",
"dependencies": {
"fastfall": "^1.5.0",
"fastparallel": "^2.2.0",
"fastq": "^1.3.0",
"fastseries": "^1.7.0",
"reusify": "^1.0.0"
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
@@ -779,6 +972,15 @@
"engines": {
"node": ">=12"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+4 -1
View File
@@ -15,9 +15,12 @@
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"fastify": "^5.8.5"
"fastify": "^5.8.5",
"nodemailer": "^8.0.7"
},
"devDependencies": {
"prisma": "5.22.0"
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AuthCode" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
CONSTRAINT "AuthCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "AuthCode_email_purpose_idx" ON "AuthCode"("email", "purpose");
-- CreateIndex
CREATE INDEX "AuthCode_expiresAt_idx" ON "AuthCode"("expiresAt");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "name" TEXT;
+27
View File
@@ -30,3 +30,30 @@ model Product {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
codes AuthCode[]
}
model AuthCode {
id String @id @default(cuid())
email String
codeHash String
purpose String
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
@@index([email, purpose])
@@index([expiresAt])
}
+15
View File
@@ -1,8 +1,10 @@
import 'dotenv/config'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js'
const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '')
@@ -17,7 +19,20 @@ await fastify.register(cors, {
credentials: true,
})
await fastify.register(jwt, {
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
})
fastify.decorate('authenticate', async function authenticate(request, reply) {
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: 'Не авторизован' })
}
})
registerAuth(fastify)
await registerAuthRoutes(fastify)
await registerApiRoutes(fastify)
fastify.get('/health', async () => ({ ok: true }))
+64
View File
@@ -0,0 +1,64 @@
import crypto from 'node:crypto'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma.js'
import { sendLoginCodeEmail } from './email.js'
export function normalizeEmail(email) {
return String(email || '').trim().toLowerCase()
}
export function randomCode6() {
return String(Math.floor(100000 + Math.random() * 900000))
}
export function sha256(input) {
return crypto.createHash('sha256').update(input).digest('hex')
}
export async function issueEmailCode({ email, purpose, userId = null }) {
const code = randomCode6()
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
await prisma.authCode.create({
data: {
email,
purpose,
userId,
codeHash: sha256(`${email}:${purpose}:${code}:${userId ?? ''}`),
expiresAt,
},
})
await sendLoginCodeEmail({ to: email, code })
}
export async function verifyEmailCode({ email, purpose, code, userId = null }) {
const now = new Date()
const codeHash = sha256(`${email}:${purpose}:${code}:${userId ?? ''}`)
const found = await prisma.authCode.findFirst({
where: {
email,
purpose,
userId,
codeHash,
usedAt: null,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
})
if (!found) return false
await prisma.authCode.update({
where: { id: found.id },
data: { usedAt: now },
})
return true
}
export async function hashPassword(password) {
return bcrypt.hash(password, 10)
}
export async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash)
}
+33
View File
@@ -0,0 +1,33 @@
import nodemailer from 'nodemailer'
function hasSmtpEnv() {
return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS)
}
export async function sendLoginCodeEmail({ to, code }) {
if (!hasSmtpEnv()) {
// dev fallback
console.log(`[DEV] login code for ${to}: ${code}`)
return
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
const from = process.env.MAIL_FROM || process.env.SMTP_USER
await transporter.sendMail({
from,
to,
subject: 'Код входа',
text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`,
})
}
+158
View File
@@ -0,0 +1,158 @@
import { prisma } from '../lib/prisma.js'
import { hashPassword, issueEmailCode, normalizeEmail, verifyEmailCode, verifyPassword } from '../lib/auth.js'
export async function registerAuthRoutes(fastify) {
fastify.post('/api/auth/request-code', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
// purpose: login (включает и регистрацию — пользователь создастся при verify)
await issueEmailCode({ email, purpose: 'login' })
return { ok: true }
})
fastify.post('/api/auth/verify-code', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
const code = String(request.body?.code || '').trim()
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
const ok = await verifyEmailCode({ email, purpose: 'login', code })
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
const user = await prisma.user.upsert({
where: { email },
update: {},
create: { email },
})
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: { id: user.id, email: user.email, name: user.name } }
})
fastify.post('/api/auth/register', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
const password = String(request.body?.password || '')
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (password.length < 8) return reply.code(400).send({ error: 'Пароль минимум 8 символов' })
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) return reply.code(409).send({ error: 'Пользователь уже существует' })
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({ data: { email, passwordHash } })
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name } })
})
fastify.post('/api/auth/login', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
const password = String(request.body?.password || '')
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (!password) return reply.code(400).send({ error: 'Укажите пароль' })
const user = await prisma.user.findUnique({ where: { email } })
if (!user?.passwordHash) return reply.code(401).send({ error: 'Неверные данные' })
const ok = await verifyPassword(password, user.passwordHash)
if (!ok) return reply.code(401).send({ error: 'Неверные данные' })
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: { id: user.id, email: user.email, name: user.name } }
})
fastify.get(
'/api/me',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return { user: null }
return { user: { id: user.id, email: user.email, name: user.name } }
},
)
fastify.post(
'/api/me/change-email/request-code',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const newEmail = normalizeEmail(request.body?.newEmail)
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
await issueEmailCode({ email: newEmail, purpose: 'change_email', userId })
return { ok: true }
},
)
fastify.post(
'/api/me/change-email/verify',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const newEmail = normalizeEmail(request.body?.newEmail)
const code = String(request.body?.code || '').trim()
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
const ok = await verifyEmailCode({ email: newEmail, purpose: 'change_email', code, userId })
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
const user = await prisma.user.update({
where: { id: userId },
data: { email: newEmail },
})
return { user: { id: user.id, email: user.email, name: user.name } }
},
)
fastify.patch(
'/api/me/password',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const currentPassword = request.body?.currentPassword ? String(request.body.currentPassword) : ''
const newPassword = String(request.body?.newPassword || '')
if (newPassword.length < 8) return reply.code(400).send({ error: 'Новый пароль минимум 8 символов' })
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
if (user.passwordHash) {
if (!currentPassword) return reply.code(400).send({ error: 'Укажите текущий пароль' })
const ok = await verifyPassword(currentPassword, user.passwordHash)
if (!ok) return reply.code(401).send({ error: 'Текущий пароль неверный' })
}
const passwordHash = await hashPassword(newPassword)
const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
return { user: { id: updated.id, email: updated.email, name: updated.name } }
},
)
fastify.patch(
'/api/me/profile',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const nameRaw = request.body?.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
const updated = await prisma.user.update({
where: { id: userId },
data: { name: name && name.length ? name : null },
})
return { user: { id: updated.id, email: updated.email, name: updated.name } }
},
)
}