diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f692107 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Required by docker-compose.yml for production deployments. +POSTGRES_PASSWORD= +ADMIN_SECRET_KEY= +ADMIN_SESSION_SECRET= +ADMIN_USERNAME= +ADMIN_PASSWORD= diff --git a/.gitignore b/.gitignore index 172f6c0..a5c1035 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dependencies **/node_modules/ +**/.npm-cache/ # env / secrets .env @@ -29,6 +30,10 @@ **/tmp/ .DS_Store .codex-tmp/ + +# Accidental local shell output files +document.documentElement.clientWidth +document.documentElement.clientWidth\}\) .claude/ .expo/ .expo-shared/ diff --git a/backend/__tests__/adminAuth.middleware.test.js b/backend/__tests__/adminAuth.middleware.test.js new file mode 100644 index 0000000..7a1b0f7 --- /dev/null +++ b/backend/__tests__/adminAuth.middleware.test.js @@ -0,0 +1,37 @@ +process.env.NODE_ENV = 'test'; +process.env.ADMIN_SECRET_KEY = 'test-admin-secret-long-enough'; + +const { adminAuthMiddleware } = require('../src/middleware/adminAuth.middleware'); + +function invoke(key) { + const req = { headers: { 'x-admin-key': key } }; + const res = { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; + const next = jest.fn(); + adminAuthMiddleware(req, res, next); + return { res, next }; +} + +describe('adminAuthMiddleware', () => { + test('accepts the configured admin key', () => { + const { next } = invoke(process.env.ADMIN_SECRET_KEY); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('rejects an invalid admin key', () => { + const { res, next } = invoke('wrong-key'); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ success: false, message: 'Forbidden' }); + }); +}); diff --git a/backend/__tests__/dataImage.test.js b/backend/__tests__/dataImage.test.js new file mode 100644 index 0000000..2aa15d1 --- /dev/null +++ b/backend/__tests__/dataImage.test.js @@ -0,0 +1,33 @@ +const { deleteMaterializedImage, hasValidSignature, materializeDataImage } = require('../src/utils/dataImage'); + +describe('data image validation', () => { + test('recognizes supported image signatures', () => { + expect(hasValidSignature(Buffer.from([0xff, 0xd8, 0xff, 0x00]), 'image/jpeg')).toBe(true); + expect( + hasValidSignature( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + 'image/png' + ) + ).toBe(true); + expect(hasValidSignature(Buffer.from('GIF89a', 'ascii'), 'image/gif')).toBe(true); + expect(hasValidSignature(Buffer.from('RIFFxxxxWEBP', 'ascii'), 'image/webp')).toBe(true); + }); + + test('rejects content that is only labeled as an image', async () => { + const fakeImage = Buffer.from('').toString('base64'); + await expect( + materializeDataImage(`data:image/png;base64,${fakeImage}`, 'test') + ).rejects.toThrow('Rasm tarkibi tanlangan formatga mos emas'); + }); +}); + +test('deleteMaterializedImage only deletes managed agency uploads', async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const image = `data:image/png;base64,${pngBuffer.toString('base64')}`; + const storedPath = await materializeDataImage(image, 'agency'); + + await expect(deleteMaterializedImage(storedPath, 'agency')).resolves.toBe(true); + await expect(deleteMaterializedImage(storedPath, 'agency')).resolves.toBe(false); + await expect(deleteMaterializedImage('https://example.com/image.png', 'agency')).resolves.toBe(false); + await expect(deleteMaterializedImage('/uploads/agency/../secret.txt', 'agency')).resolves.toBe(false); +}); diff --git a/backend/__tests__/security.test.js b/backend/__tests__/security.test.js new file mode 100644 index 0000000..7e01913 --- /dev/null +++ b/backend/__tests__/security.test.js @@ -0,0 +1,72 @@ +const request = require('supertest'); +const jwt = require('jsonwebtoken'); + +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-jwt-secret-long-enough'; +process.env.AGENCY_JWT_SECRET = 'test-agency-secret-long-enough'; +process.env.ADMIN_SECRET_KEY = 'test-admin-secret-long-enough'; + +jest.mock('../src/config/database', () => ({ + prisma: { + landingInteraction: { + create: jest.fn().mockResolvedValue({ id: 'interaction-1' }), + }, + }, +})); + +const app = require('../app'); +const { prisma } = require('../src/config/database'); + +describe('security boundaries', () => { + test('rejects unauthenticated POI writes', async () => { + const response = await request(app) + .post('/api/v1/poi') + .send({ name: 'Unauthorized place' }); + + expect(response.status).toBe(403); + expect(response.body.success).toBe(false); + }); + + test('rejects booking history lookup without a bearer token', async () => { + const response = await request(app) + .get('/api/v1/bookings/mine') + .query({ email: 'victim@example.com' }); + + expect(response.status).toBe(401); + expect(response.body.success).toBe(false); + }); + + test('does not trust a client-supplied interaction userId', async () => { + const response = await request(app) + .post('/api/v1/home/interactions') + .send({ + entityType: 'place', + entityId: 'place-1', + eventType: 'view', + userId: 'spoofed-user', + }); + + expect(response.status).toBe(200); + expect(prisma.landingInteraction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ userId: null }), + }); + }); + + test('uses the authenticated identity for interactions', async () => { + const token = jwt.sign({ id: 'real-user' }, process.env.JWT_SECRET); + const response = await request(app) + .post('/api/v1/home/interactions') + .set('authorization', `Bearer ${token}`) + .send({ + entityType: 'place', + entityId: 'place-1', + eventType: 'view', + userId: 'spoofed-user', + }); + + expect(response.status).toBe(200); + expect(prisma.landingInteraction.create).toHaveBeenLastCalledWith({ + data: expect.objectContaining({ userId: 'real-user' }), + }); + }); +}); diff --git a/backend/app.js b/backend/app.js index 2e34b43..bb91d04 100644 --- a/backend/app.js +++ b/backend/app.js @@ -4,6 +4,7 @@ const helmet = require('helmet'); const path = require('path'); const { loggerMiddleware } = require('./src/middleware/logger.middleware'); const { rateLimiter } = require('./src/middleware/rateLimit.middleware'); +const { logger } = require('./src/config/logger'); const routes = require('./src/routes/index'); const app = express(); @@ -286,11 +287,16 @@ if (process.env.NODE_ENV === 'production') { app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' }, })); -app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*' })); +const allowedOrigins = process.env.ALLOWED_ORIGINS + ?.split(',') + .map((origin) => origin.trim()) + .filter(Boolean); +app.use(cors({ origin: allowedOrigins?.length ? allowedOrigins : '*' })); app.use('/uploads', express.static(path.join(__dirname, 'uploads'), { fallthrough: false, maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0, })); +app.use('/api/v1', rateLimiter); app.use(express.json({ limit: '12mb' })); app.use(loggerMiddleware); @@ -307,11 +313,21 @@ app.get(['/privacy-policy', '/privacy'], (req, res) => { res.status(200).send(renderPrivacyPolicyPage(baseUrl)); }); -app.use('/api/v1', rateLimiter, routes); +app.use('/api/v1', routes); app.use((err, req, res, next) => { const status = err.status || 500; - res.status(status).json({ success: false, message: err.message || 'Internal Server Error' }); + if (status >= 500) { + logger.error('Unhandled request error', { + message: err.message, + method: req.method, + path: req.originalUrl, + }); + } + const message = status >= 500 && process.env.NODE_ENV === 'production' + ? 'Internal Server Error' + : err.message || 'Internal Server Error'; + res.status(status).json({ success: false, message }); }); module.exports = app; diff --git a/backend/package-lock.json b/backend/package-lock.json index c3f1e87..58f3717 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1287,6 +1287,41 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1379,14 +1414,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-jest": { @@ -1545,9 +1581,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1558,7 +1594,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -1569,9 +1605,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2384,14 +2420,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -2410,7 +2446,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -2526,9 +2562,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -2853,6 +2889,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4177,9 +4249,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz", - "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -4225,9 +4297,9 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -4526,9 +4598,9 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/picocolors": { @@ -4539,9 +4611,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4649,10 +4721,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8", @@ -4679,9 +4754,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/backend/package.json b/backend/package.json index b99195f..6ef38ad 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "db:migrate": "npx prisma migrate dev", "db:seed": "node src/utils/seed.js", "db:studio": "npx prisma studio", - "test": "jest --coverage --passWithNoTests" + "test": "jest --coverage" }, "dependencies": { "@prisma/client": "^5.7.0", diff --git a/backend/prisma/migrations/20260607170000_agency_email_images_booking_deadline/migration.sql b/backend/prisma/migrations/20260607170000_agency_email_images_booking_deadline/migration.sql new file mode 100644 index 0000000..a1fcc8d --- /dev/null +++ b/backend/prisma/migrations/20260607170000_agency_email_images_booking_deadline/migration.sql @@ -0,0 +1,25 @@ +ALTER TYPE "AuthCodeType" ADD VALUE IF NOT EXISTS 'EMAIL_CHANGE'; + +ALTER TABLE "AgencyAccount" + ADD COLUMN IF NOT EXISTS "pendingEmail" TEXT, + ADD COLUMN IF NOT EXISTS "emailChangeResendCount" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "emailChangeRequestedAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX IF NOT EXISTS "AgencyAccount_pendingEmail_key" + ON "AgencyAccount"("pendingEmail"); + +ALTER TABLE "AgencyApplication" + ADD COLUMN IF NOT EXISTS "imageUrl" TEXT; + +ALTER TABLE "Tour" + ADD COLUMN IF NOT EXISTS "responseTimeMinutes" INTEGER NOT NULL DEFAULT 45; + +ALTER TABLE "TourBooking" + ADD COLUMN IF NOT EXISTS "responseDeadlineAt" TIMESTAMP(3); + +UPDATE "TourBooking" AS booking +SET "responseDeadlineAt" = + booking."createdAt" + make_interval(mins => COALESCE(tour."responseTimeMinutes", 45)) +FROM "Tour" AS tour +WHERE booking."tourId" = tour."id" + AND booking."responseDeadlineAt" IS NULL; diff --git a/backend/prisma/migrations/20260607193000_user_email_change_account_delete_codes/migration.sql b/backend/prisma/migrations/20260607193000_user_email_change_account_delete_codes/migration.sql new file mode 100644 index 0000000..bdd62d5 --- /dev/null +++ b/backend/prisma/migrations/20260607193000_user_email_change_account_delete_codes/migration.sql @@ -0,0 +1,10 @@ +ALTER TYPE "AuthCodeType" ADD VALUE IF NOT EXISTS 'ACCOUNT_DELETE'; + +ALTER TABLE "User" +ADD COLUMN "pendingEmail" TEXT, +ADD COLUMN "emailChangeResendCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "emailChangeRequestedAt" TIMESTAMP(3), +ADD COLUMN "accountDeleteResendCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "accountDeleteRequestedAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX "User_pendingEmail_key" ON "User"("pendingEmail"); diff --git a/backend/prisma/migrations/20260609143000_agency_google_auth/migration.sql b/backend/prisma/migrations/20260609143000_agency_google_auth/migration.sql new file mode 100644 index 0000000..f02072b --- /dev/null +++ b/backend/prisma/migrations/20260609143000_agency_google_auth/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "AgencyAccount" + ADD COLUMN IF NOT EXISTS "googleId" TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS "AgencyAccount_googleId_key" + ON "AgencyAccount"("googleId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ff16a6b..c6cf5bc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -16,6 +16,8 @@ enum AuthProvider { enum AuthCodeType { EMAIL_VERIFICATION PASSWORD_RESET + EMAIL_CHANGE + ACCOUNT_DELETE } enum FeedbackCategory { @@ -27,21 +29,26 @@ enum FeedbackCategory { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String lastName String? bio String? avatarUrl String? - email String @unique + email String @unique + pendingEmail String? @unique password String? - googleId String? @unique - authProvider AuthProvider @default(LOCAL) - emailVerified Boolean @default(false) + googleId String? @unique + authProvider AuthProvider @default(LOCAL) + emailVerified Boolean @default(false) emailVerifiedAt DateTime? lastLoginAt DateTime? - blocked Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + blocked Boolean @default(false) + emailChangeResendCount Int @default(0) + emailChangeRequestedAt DateTime? + accountDeleteResendCount Int @default(0) + accountDeleteRequestedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt trips Trip[] authCodes AuthCode[] preference UserPreference? @@ -66,18 +73,22 @@ model AuthCode { } model AgencyAccount { - id String @id @default(cuid()) - email String @unique - passwordHash String - emailVerified Boolean @default(false) - emailVerifiedAt DateTime? - status String @default("pending") - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - authCodes AgencyAuthCode[] - applications AgencyApplication[] - agencies TourAgency[] + id String @id @default(cuid()) + email String @unique + googleId String? @unique + pendingEmail String? @unique + passwordHash String + emailVerified Boolean @default(false) + emailVerifiedAt DateTime? + emailChangeResendCount Int @default(0) + emailChangeRequestedAt DateTime? + status String @default("pending") + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + authCodes AgencyAuthCode[] + applications AgencyApplication[] + agencies TourAgency[] @@index([status]) @@index([emailVerified]) @@ -98,30 +109,31 @@ model AgencyAuthCode { } model AgencyApplication { - id String @id @default(cuid()) + id String @id @default(cuid()) accountId String - account AgencyAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) + account AgencyAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) agencyId String? - agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) + agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) companyName String legalName String? contactPerson String phone String email String city String - country String @default("Global") + country String @default("Global") website String? telegram String? instagram String? - serviceTypes String[] @default([]) + serviceTypes String[] @default([]) description String + imageUrl String? documents Json? - status String @default("draft") + status String @default("draft") adminNote String? submittedAt DateTime? reviewedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([accountId]) @@index([agencyId]) @@ -129,49 +141,49 @@ model AgencyApplication { } model Destination { - id String @id @default(cuid()) - slug String @unique - name String - region String - description String - imageUrl String - rating Float - reviewCount Int - categories String[] - tags String[] - budgetDaily Int - midDaily Int - luxuryDaily Int - trainPrice Int @default(0) - trainDuration String @default("") - busPrice Int @default(0) - busDuration String @default("") - flightPrice Int @default(0) - flightDuration String @default("") - landmarks Json - hotels Json - foodBudget Int @default(0) - foodMid Int @default(0) - foodLuxury Int @default(0) - bestSeasons String[] - minDays Int - maxDays Int - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.65) - verifiedBy String? - seasonality Json? - openingHours Json? - priceUpdatedAt DateTime? - coverageTier String @default("starter") - createdAt DateTime @default(now()) + id String @id @default(cuid()) + slug String @unique + name String + region String + description String + imageUrl String + rating Float + reviewCount Int + categories String[] + tags String[] + budgetDaily Int + midDaily Int + luxuryDaily Int + trainPrice Int @default(0) + trainDuration String @default("") + busPrice Int @default(0) + busDuration String @default("") + flightPrice Int @default(0) + flightDuration String @default("") + landmarks Json + hotels Json + foodBudget Int @default(0) + foodMid Int @default(0) + foodLuxury Int @default(0) + bestSeasons String[] + minDays Int + maxDays Int + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.65) + verifiedBy String? + seasonality Json? + openingHours Json? + priceUpdatedAt DateTime? + coverageTier String @default("starter") + createdAt DateTime @default(now()) } model Trip { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) title String totalCost Int perPersonCost Int @@ -181,8 +193,8 @@ model Trip { travelers Int duration Int planData Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt tripReviews TripReview[] } @@ -197,37 +209,37 @@ model UserPreference { } model Poi { - id String @id @default(cuid()) - name String - city String - slug String - type String - subtype String? - lat Float - lng Float - info String - description String? - imageUrl String? - rating Float? - ratingCount Int? - priceLevel Int? - price Int? - icon String - openingHours Json? - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.55) - verifiedBy String? + id String @id @default(cuid()) + name String + city String + slug String + type String + subtype String? + lat Float + lng Float + info String + description String? + imageUrl String? + rating Float? + ratingCount Int? + priceLevel Int? + price Int? + icon String + openingHours Json? + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.55) + verifiedBy String? duplicateGroupId String? - priceUpdatedAt DateTime? - featured Boolean @default(false) - manualBoost Float @default(0) - qualityScore Float @default(0.7) - landingSortOrder Int @default(0) - landingActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + priceUpdatedAt DateTime? + featured Boolean @default(false) + manualBoost Float @default(0) + qualityScore Float @default(0.7) + landingSortOrder Int @default(0) + landingActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt wishlistItems WishlistItem[] @@ -242,42 +254,42 @@ model Poi { } model TransportProvider { - id String @id @default(cuid()) - slug String @unique - name String - type String - website String? - supportPhone String? - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.65) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - routes TransportRoute[] + id String @id @default(cuid()) + slug String @unique + name String + type String + website String? + supportPhone String? + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.65) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + routes TransportRoute[] } model TransportRoute { - id String @id @default(cuid()) - fromCity String - toCity String - mode String - providerId String? - provider TransportProvider? @relation(fields: [providerId], references: [id], onDelete: SetNull) - priceMin Int - priceMax Int + id String @id @default(cuid()) + fromCity String + toCity String + mode String + providerId String? + provider TransportProvider? @relation(fields: [providerId], references: [id], onDelete: SetNull) + priceMin Int + priceMax Int durationMinutes Int - distanceKm Float? - scheduleNote String - bookingUrl String? - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.6) - whyRecommended String? - active Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + distanceKm Float? + scheduleNote String + bookingUrl String? + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.6) + whyRecommended String? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([fromCity, toCity]) @@index([mode]) @@ -285,98 +297,98 @@ model TransportRoute { } model CityPack { - id String @id @default(cuid()) - city String @unique - version Int @default(1) - offlineReady Boolean @default(false) - poiCount Int @default(0) - destinationCount Int @default(0) - transportRouteCount Int @default(0) - emergencyContacts Json? - transportNotes Json? - sourceSummary Json? - updatedAt DateTime @updatedAt - createdAt DateTime @default(now()) + id String @id @default(cuid()) + city String @unique + version Int @default(1) + offlineReady Boolean @default(false) + poiCount Int @default(0) + destinationCount Int @default(0) + transportRouteCount Int @default(0) + emergencyContacts Json? + transportNotes Json? + sourceSummary Json? + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) } model HomeHeroSlide { - id String @id @default(cuid()) - slug String @unique + id String @id @default(cuid()) + slug String @unique title String subtitle String? imageUrl String actionUrl String? placeSlug String? - sortOrder Int @default(0) - active Boolean @default(true) - source String @default("admin") + sortOrder Int @default(0) + active Boolean @default(true) + source String @default("admin") sourceUrl String? lastVerifiedAt DateTime? - confidenceScore Float @default(0.8) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + confidenceScore Float @default(0.8) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([active, sortOrder]) } model TravelerStory { - id String @id @default(cuid()) - slug String @unique + id String @id @default(cuid()) + slug String @unique quote String authorName String authorRole String avatar String? avatarColor String? - rating Int @default(5) - sortOrder Int @default(0) - active Boolean @default(true) - source String @default("admin") + rating Int @default(5) + sortOrder Int @default(0) + active Boolean @default(true) + source String @default("admin") sourceUrl String? lastVerifiedAt DateTime? - confidenceScore Float @default(0.8) - featured Boolean @default(false) - manualBoost Float @default(0) - qualityScore Float @default(0.8) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + confidenceScore Float @default(0.8) + featured Boolean @default(false) + manualBoost Float @default(0) + qualityScore Float @default(0.8) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([active, sortOrder]) @@index([featured]) } model TourAgency { - id String @id @default(cuid()) - slug String @unique - ownerAccountId String? - ownerAccount AgencyAccount? @relation(fields: [ownerAccountId], references: [id], onDelete: SetNull) - name String - city String - description String? - specialty String - rating Float @default(0) - reviews Int @default(0) - toursCount Int @default(0) - phone String? - website String? - imageUrl String? - active Boolean @default(true) - source String @default("admin") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.7) - featured Boolean @default(false) - manualBoost Float @default(0) - qualityScore Float @default(0.7) - landingSortOrder Int @default(0) - approvalStatus String @default("approved") - approvedAt DateTime? - rejectedAt DateTime? - adminNote String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - tours Tour[] - applications AgencyApplication[] - bookings TourBooking[] + id String @id @default(cuid()) + slug String @unique + ownerAccountId String? + ownerAccount AgencyAccount? @relation(fields: [ownerAccountId], references: [id], onDelete: SetNull) + name String + city String + description String? + specialty String + rating Float @default(0) + reviews Int @default(0) + toursCount Int @default(0) + phone String? + website String? + imageUrl String? + active Boolean @default(true) + source String @default("admin") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.7) + featured Boolean @default(false) + manualBoost Float @default(0) + qualityScore Float @default(0.7) + landingSortOrder Int @default(0) + approvalStatus String @default("approved") + approvedAt DateTime? + rejectedAt DateTime? + adminNote String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tours Tour[] + applications AgencyApplication[] + bookings TourBooking[] @@index([active]) @@index([city]) @@ -405,35 +417,36 @@ model LandingInteraction { } model Tour { - id String @id @default(cuid()) - slug String @unique - title String - city String - subtitle String - description String? - duration String - price String? - priceMin Int? - rating Float @default(0) - badge String @default("Latest") - imageUrl String? - itinerary Json? - highlights String[] @default([]) - active Boolean @default(true) - agencyId String? - agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) - approvalStatus String @default("approved") - submittedAt DateTime? - approvedAt DateTime? - rejectedAt DateTime? - adminNote String? - source String @default("admin") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.7) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - bookings TourBooking[] + id String @id @default(cuid()) + slug String @unique + title String + city String + subtitle String + description String? + duration String + responseTimeMinutes Int @default(45) + price String? + priceMin Int? + rating Float @default(0) + badge String @default("Latest") + imageUrl String? + itinerary Json? + highlights String[] @default([]) + active Boolean @default(true) + agencyId String? + agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) + approvalStatus String @default("approved") + submittedAt DateTime? + approvedAt DateTime? + rejectedAt DateTime? + adminNote String? + source String @default("admin") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.7) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + bookings TourBooking[] @@index([active]) @@index([badge]) @@ -445,31 +458,32 @@ model Tour { } model TourBooking { - id String @id @default(cuid()) - tourId String - tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) - agencyId String? - agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) - userId String? - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - customerName String - customerEmail String - customerPhone String? - travelers Int @default(1) - travelDate DateTime? - message String? - status String @default("pending") - totalEstimate Int? - currency String @default("USD") - source String @default("mobile") - agencyNote String? - adminNote String? - confirmedAt DateTime? - rejectedAt DateTime? - cancelledAt DateTime? - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + tourId String + tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) + agencyId String? + agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + customerName String + customerEmail String + customerPhone String? + travelers Int @default(1) + travelDate DateTime? + message String? + status String @default("pending") + responseDeadlineAt DateTime? + totalEstimate Int? + currency String @default("USD") + source String @default("mobile") + agencyNote String? + adminNote String? + confirmedAt DateTime? + rejectedAt DateTime? + cancelledAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([tourId]) @@index([agencyId, status]) @@ -479,20 +493,20 @@ model TourBooking { } model WishlistItem { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - poiId String? - poi Poi? @relation(fields: [poiId], references: [id], onDelete: SetNull) - name String - city String - slug String - type String - icon String - savedAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + poiId String? + poi Poi? @relation(fields: [poiId], references: [id], onDelete: SetNull) + name String + city String + slug String + type String + icon String + savedAt DateTime @default(now()) - @@index([userId, savedAt]) @@unique([userId, slug]) + @@index([userId, savedAt]) } model Feedback { diff --git a/backend/server.js b/backend/server.js index ba538f4..42219de 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,6 +6,9 @@ if (process.env.NODE_ENV !== 'production') { process.env.ADMIN_SECRET_KEY ||= 'change_me'; } +const { validateProductionSecurityConfig } = require('./src/config/security'); +validateProductionSecurityConfig(); + const app = require('./app'); const { logger } = require('./src/config/logger'); const { connectRedis } = require('./src/config/redis'); diff --git a/backend/src/config/security.js b/backend/src/config/security.js new file mode 100644 index 0000000..88fee12 --- /dev/null +++ b/backend/src/config/security.js @@ -0,0 +1,47 @@ +const INSECURE_SECRET_VALUES = new Set([ + 'change_me', + 'change_me_to_random_long_secret', + 'change_me_to_random_long_agency_secret', + 'travelorai_secret', + 'travelorai_agency_secret', + 'travelorai_admin_session_change_me', + 'dev-admin-session-secret', +]); + +function isProduction() { + return process.env.NODE_ENV === 'production'; +} + +function getSecret(name, developmentFallback) { + const value = String(process.env[name] || '').trim(); + if (value && (!isProduction() || !INSECURE_SECRET_VALUES.has(value))) { + return value; + } + + if (!isProduction() && developmentFallback) { + return developmentFallback; + } + + throw new Error(`${name} must be set to a strong, non-default value`); +} + +function validateProductionSecurityConfig() { + if (!isProduction()) return; + + getSecret('JWT_SECRET'); + getSecret('AGENCY_JWT_SECRET'); + getSecret('ADMIN_SECRET_KEY'); + + const origins = String(process.env.ALLOWED_ORIGINS || '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + if (origins.length === 0 || origins.includes('*')) { + throw new Error('ALLOWED_ORIGINS must contain explicit production origins'); + } +} + +module.exports = { + getSecret, + validateProductionSecurityConfig, +}; diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 5eb4d3f..d7275ee 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -6,6 +6,7 @@ const { formatBooking } = require('./bookings.controller'); const crypto = require('crypto'); const fs = require('fs/promises'); const path = require('path'); +const { deleteMaterializedImage } = require('../utils/dataImage'); // ── Helpers ────────────────────────────────────────────────────────────────── @@ -909,6 +910,7 @@ async function approveAgencyApplication(req, res) { specialty, phone: application.phone, website: application.website, + imageUrl: application.imageUrl, active: true, source: 'agency_portal', confidenceScore: 0.85, @@ -956,44 +958,34 @@ async function approveAgencyApplication(req, res) { async function rejectAgencyApplication(req, res) { try { - const { adminNote } = adminReviewSchema.parse(req.body || {}); + adminReviewSchema.parse(req.body || {}); const application = await prisma.agencyApplication.findUnique({ where: { id: req.params.id }, + include: { agency: true }, }); if (!application) return error(res, 'Agency ariza topilmadi', 404); - const now = new Date(); const operations = [ - prisma.agencyApplication.update({ + prisma.agencyApplication.delete({ where: { id: application.id }, - data: { - status: 'rejected', - reviewedAt: now, - adminNote: adminNote || null, - }, }), prisma.agencyAccount.update({ where: { id: application.accountId }, - data: { status: 'rejected' }, + data: { status: 'pending' }, }), ]; - if (application.agencyId) { + if (application.agencyId && application.agency?.approvalStatus !== 'approved') { operations.push( - prisma.tourAgency.update({ + prisma.tourAgency.delete({ where: { id: application.agencyId }, - data: { - active: false, - approvalStatus: 'rejected', - rejectedAt: now, - adminNote: adminNote || null, - }, }) ); } - const [updated] = await prisma.$transaction(operations); - return success(res, updated); + await prisma.$transaction(operations); + await deleteMaterializedImage(application.imageUrl, 'agency').catch(() => false); + return success(res, { id: application.id, deleted: true }); } catch (err) { return error(res, err.errors?.[0]?.message || err.message, 400); } diff --git a/backend/src/controllers/agency.controller.js b/backend/src/controllers/agency.controller.js index 5abf332..1c5499d 100644 --- a/backend/src/controllers/agency.controller.js +++ b/backend/src/controllers/agency.controller.js @@ -3,11 +3,16 @@ const crypto = require('crypto'); const { prisma } = require('../config/database'); const { success, error } = require('../utils/response'); const { signAgencyToken } = require('../utils/agencyJwt'); -const { sendVerificationCodeEmail } = require('../services/email.service'); +const { sendEmailChangeCodeEmail, sendVerificationCodeEmail } = require('../services/email.service'); +const { verifyGoogleIdToken } = require('../services/auth.service'); +const { materializeDataImage } = require('../utils/dataImage'); const { bookingStatusSchema } = require('../schemas/booking.schema'); const { formatBooking } = require('./bookings.controller'); const { applicationSchema, + emailChangeConfirmSchema, + emailChangeRequestSchema, + googleAuthSchema, loginSchema, registerSchema, tourSchema, @@ -15,6 +20,8 @@ const { } = require('../schemas/agency.schema'); const CODE_EXPIRES_MINUTES = Number(process.env.AGENCY_CODE_EXPIRES_MINUTES || 10); +const EMAIL_CHANGE_MAX_RESENDS = 3; +const SUPPORT_EMAIL = process.env.SUPPORT_EMAIL || 'support@travelorai.local'; function slugify(text) { return String(text || '') @@ -32,7 +39,7 @@ function hashCode(code) { } function generateCode() { - return String(Math.floor(100000 + Math.random() * 900000)); + return String(crypto.randomInt(100000, 1000000)); } function publicAccount(account) { @@ -40,7 +47,10 @@ function publicAccount(account) { return { id: account.id, email: account.email, + pendingEmail: account.pendingEmail || null, emailVerified: account.emailVerified, + emailChangeResendCount: account.emailChangeResendCount || 0, + emailChangeResendsRemaining: Math.max(0, EMAIL_CHANGE_MAX_RESENDS - Number(account.emailChangeResendCount || 0)), status: account.status, createdAt: account.createdAt, updatedAt: account.updatedAt, @@ -111,14 +121,14 @@ async function uniqueTourSlug(base, currentId) { return slug; } -async function issueAgencyCode(account) { +async function issueAgencyCode(account, type = 'EMAIL_VERIFICATION') { const code = generateCode(); const expiresAt = new Date(Date.now() + CODE_EXPIRES_MINUTES * 60 * 1000); await prisma.agencyAuthCode.updateMany({ where: { accountId: account.id, - type: 'EMAIL_VERIFICATION', + type, usedAt: null, }, data: { usedAt: new Date() }, @@ -127,34 +137,49 @@ async function issueAgencyCode(account) { await prisma.agencyAuthCode.create({ data: { accountId: account.id, - type: 'EMAIL_VERIFICATION', + type, codeHash: hashCode(code), expiresAt, }, }); - const delivery = await sendVerificationCodeEmail({ - email: account.email, - name: account.email, - code, - expiresInMinutes: CODE_EXPIRES_MINUTES, - }); + const delivery = + type === 'EMAIL_CHANGE' + ? await sendEmailChangeCodeEmail({ + email: account.email, + name: account.email, + newEmail: account.pendingEmail, + code, + expiresInMinutes: CODE_EXPIRES_MINUTES, + }) + : await sendVerificationCodeEmail({ + email: account.email, + name: account.email, + code, + expiresInMinutes: CODE_EXPIRES_MINUTES, + }); return delivery; } -async function consumeAgencyCode(accountId, code) { +async function consumeAgencyCode(accountId, code, type = 'EMAIL_VERIFICATION') { const item = await prisma.agencyAuthCode.findFirst({ where: { accountId, - type: 'EMAIL_VERIFICATION', + type, usedAt: null, expiresAt: { gt: new Date() }, }, orderBy: { createdAt: 'desc' }, }); - if (!item || item.codeHash !== hashCode(code)) return false; + if (!item) return false; + const expectedHash = Buffer.from(item.codeHash, 'hex'); + const actualHash = Buffer.from(hashCode(code), 'hex'); + if ( + expectedHash.length !== actualHash.length || + !crypto.timingSafeEqual(expectedHash, actualHash) + ) return false; await prisma.agencyAuthCode.update({ where: { id: item.id }, @@ -163,6 +188,114 @@ async function consumeAgencyCode(accountId, code) { return true; } +async function requestEmailChange(req, res) { + try { + const input = emailChangeRequestSchema.parse(req.body || {}); + const newEmail = input.newEmail.toLowerCase(); + const account = req.agencyAccount; + if (newEmail === account.email) return error(res, 'Yangi email hozirgi emaildan farq qilishi kerak', 400); + if (account.pendingEmail) { + return error(res, 'Avval boshlangan email almashtirishni kod bilan tasdiqlang', 409); + } + + const conflict = await prisma.agencyAccount.findFirst({ + where: { + id: { not: account.id }, + OR: [{ email: newEmail }, { pendingEmail: newEmail }], + }, + }); + if (conflict) return error(res, 'Bu email boshqa agency akkauntida ishlatilgan', 409); + + const updated = await prisma.agencyAccount.update({ + where: { id: account.id }, + data: { + pendingEmail: newEmail, + emailChangeResendCount: 0, + emailChangeRequestedAt: new Date(), + }, + }); + const delivery = await issueAgencyCode(updated, 'EMAIL_CHANGE'); + return success(res, { + account: publicAccount(updated), + delivery, + message: `Tasdiqlash kodi eski emailingizga (${updated.email}) yuborildi.`, + supportEmail: SUPPORT_EMAIL, + }); + } catch (err) { + return error(res, err.errors?.[0]?.message || err.message, 400); + } +} + +async function resendEmailChange(req, res) { + try { + const account = await prisma.agencyAccount.findUnique({ where: { id: req.agencyAccount.id } }); + if (!account?.pendingEmail) return error(res, 'Email almashtirish so‘rovi topilmadi', 404); + if (account.emailChangeResendCount >= EMAIL_CHANGE_MAX_RESENDS) { + return error(res, `Kod 3 marta qayta yuborildi. ${SUPPORT_EMAIL} orqali adminga murojaat qiling.`, 429, { + code: 'EMAIL_CHANGE_RESEND_LIMIT', + supportEmail: SUPPORT_EMAIL, + }); + } + + const updated = await prisma.agencyAccount.update({ + where: { id: account.id }, + data: { emailChangeResendCount: { increment: 1 } }, + }); + const delivery = await issueAgencyCode(updated, 'EMAIL_CHANGE'); + return success(res, { + account: publicAccount(updated), + delivery, + message: 'Kod eski emailga qayta yuborildi.', + supportEmail: SUPPORT_EMAIL, + }); + } catch (err) { + return error(res, err.message, 400); + } +} + +async function confirmEmailChange(req, res) { + try { + const input = emailChangeConfirmSchema.parse(req.body || {}); + const account = await prisma.agencyAccount.findUnique({ where: { id: req.agencyAccount.id } }); + if (!account?.pendingEmail) return error(res, 'Email almashtirish so‘rovi topilmadi', 404); + + const ok = await consumeAgencyCode(account.id, input.code, 'EMAIL_CHANGE'); + if (!ok) return error(res, 'Kod xato yoki muddati tugagan', 400); + + const conflict = await prisma.agencyAccount.findFirst({ + where: { id: { not: account.id }, email: account.pendingEmail }, + }); + if (conflict) return error(res, 'Yangi email boshqa akkaunt tomonidan band qilingan', 409); + + const [updated] = await prisma.$transaction([ + prisma.agencyAccount.update({ + where: { id: account.id }, + data: { + email: account.pendingEmail, + pendingEmail: null, + emailChangeResendCount: 0, + emailChangeRequestedAt: null, + emailVerified: true, + emailVerifiedAt: new Date(), + }, + }), + prisma.agencyApplication.updateMany({ + where: { accountId: account.id }, + data: { email: account.pendingEmail }, + }), + ]); + + const token = signAgencyToken({ id: updated.id, email: updated.email, role: 'agency' }); + return success(res, { + token, + account: publicAccount(updated), + message: 'Email muvaffaqiyatli almashtirildi.', + }); + } catch (err) { + return error(res, err.errors?.[0]?.message || err.message, 400); + } +} + async function getLatestApplication(accountId) { return prisma.agencyApplication.findFirst({ where: { accountId }, @@ -344,6 +477,61 @@ async function login(req, res) { } } +async function googleAuth(req, res) { + try { + const input = googleAuthSchema.parse(req.body || {}); + const googleProfile = await verifyGoogleIdToken(input.idToken); + let account = await prisma.agencyAccount.findFirst({ + where: { + OR: [{ googleId: googleProfile.googleId }, { email: googleProfile.email }], + }, + }); + + if (account?.status === 'blocked') { + return error(res, 'Agency akkaunt bloklangan', 403); + } + + if (!account) { + const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10); + account = await prisma.agencyAccount.create({ + data: { + email: googleProfile.email, + googleId: googleProfile.googleId, + passwordHash, + emailVerified: true, + emailVerifiedAt: new Date(), + lastLoginAt: new Date(), + status: 'pending', + }, + }); + } else { + account = await prisma.agencyAccount.update({ + where: { id: account.id }, + data: { + googleId: account.googleId || googleProfile.googleId, + emailVerified: true, + emailVerifiedAt: account.emailVerifiedAt || new Date(), + lastLoginAt: new Date(), + }, + }); + } + + const token = signAgencyToken({ id: account.id, email: account.email, role: 'agency' }); + return success(res, { token, account: publicAccount(account) }); + } catch (err) { + if (err.response?.status === 400) { + return error(res, 'Google token yaroqsiz yoki muddati tugagan.', 401); + } + if (err.message === 'GOOGLE_AUDIENCE_MISMATCH') { + return error(res, 'Google client ID mos kelmadi.', 401); + } + if (err.message === 'GOOGLE_EMAIL_NOT_VERIFIED') { + return error(res, 'Google akkauntdagi email tasdiqlanmagan.', 401); + } + return error(res, err.errors?.[0]?.message || err.message, 400); + } +} + async function me(req, res) { try { const [application, agency] = await Promise.all([ @@ -375,6 +563,7 @@ async function me(req, res) { return acc; }, {}), bookingStats, + supportEmail: SUPPORT_EMAIL, }); } catch (err) { return error(res, err.message, 500); @@ -399,6 +588,9 @@ async function upsertApplication(req, res) { if (existing?.status === 'approved') { return error(res, 'Tasdiqlangan arizani onboardingdan ozgartirib bolmaydi', 409); } + if (existing?.status === 'pending') { + return error(res, 'Ariza admin tekshiruvida. Qaror chiqmaguncha ozgartirib bolmaydi', 409); + } const data = { ...input, @@ -406,6 +598,7 @@ async function upsertApplication(req, res) { website: input.website || null, telegram: input.telegram || null, instagram: input.instagram || null, + imageUrl: input.imageUrl ? await materializeDataImage(input.imageUrl, 'agency') : null, status: existing?.status === 'pending' ? 'pending' : 'draft', }; @@ -427,6 +620,7 @@ async function submitApplication(req, res) { const application = await getLatestApplication(req.agencyAccount.id); if (!application) return error(res, 'Avval ariza formasini toldiring', 400); if (application.status === 'approved') return error(res, 'Ariza allaqachon tasdiqlangan', 409); + if (application.status === 'pending') return error(res, 'Ariza allaqachon admin tekshiruvida', 409); applicationSchema.parse({ companyName: application.companyName, @@ -495,7 +689,8 @@ async function createTour(req, res) { description: input.description || null, price: input.price || null, priceMin: input.priceMin ?? null, - imageUrl: input.imageUrl || null, + imageUrl: input.imageUrl ? await materializeDataImage(input.imageUrl, 'agency') : null, + responseTimeMinutes: input.responseTimeMinutes, slug, agencyId: agency.id, source: 'agency_portal', @@ -529,7 +724,10 @@ async function updateTour(req, res) { ...(input.description !== undefined ? { description: input.description || null } : {}), ...(input.price !== undefined ? { price: input.price || null } : {}), ...(input.priceMin !== undefined ? { priceMin: input.priceMin ?? null } : {}), - ...(input.imageUrl !== undefined ? { imageUrl: input.imageUrl || null } : {}), + ...(input.imageUrl !== undefined + ? { imageUrl: input.imageUrl ? await materializeDataImage(input.imageUrl, 'agency') : null } + : {}), + ...(input.responseTimeMinutes !== undefined ? { responseTimeMinutes: input.responseTimeMinutes } : {}), approvalStatus: nextStatus, active: false, submittedAt: nextStatus === 'pending_review' ? new Date() : existing.submittedAt, @@ -646,7 +844,7 @@ async function updateAgencyProfile(req, res) { const agency = await ensureApprovedAgency(req, res); if (!agency) return; const required = ['name', 'city', 'specialty']; - const nullable = ['description', 'phone', 'website', 'imageUrl']; + const nullable = ['description', 'phone', 'website']; const data = {}; for (const key of required) { if (req.body?.[key] !== undefined) { @@ -658,6 +856,11 @@ async function updateAgencyProfile(req, res) { for (const key of nullable) { if (req.body?.[key] !== undefined) data[key] = req.body[key] ? String(req.body[key]).trim() : null; } + if (req.body?.imageUrl !== undefined) { + data.imageUrl = req.body.imageUrl + ? await materializeDataImage(req.body.imageUrl, 'agency') + : null; + } if (data.name && data.name !== agency.name) data.slug = await uniqueAgencySlug(slugify(data.name), agency.id); const updated = await prisma.tourAgency.update({ @@ -673,7 +876,11 @@ async function updateAgencyProfile(req, res) { module.exports = { register, verifyEmail, + requestEmailChange, + resendEmailChange, + confirmEmailChange, login, + googleAuth, me, getApplication, upsertApplication, diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index e0f81a6..e92ba18 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -18,6 +18,7 @@ const DEFAULT_PREFERENCES = { interests: ['tarixiy', 'madaniy'], updatedAt: null, }; +const MAX_SECURITY_CODE_REQUESTS = 3; function mapPreferences(pref) { if (!pref) return DEFAULT_PREFERENCES; @@ -52,40 +53,33 @@ async function register(req, res) { const normalizedEmail = normalizeEmail(email); const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } }); - if (existing?.emailVerified) { - return error(res, 'Bu email allaqachon ro\'yxatdan o\'tgan.', 409, { - authProvider: existing.googleId ? 'google' : 'local', - }); - } - - if (existing?.googleId && !existing.password) { - return error(res, 'Bu email Google orqali ro\'yxatdan o\'tgan. Google bilan kiring.', 409, { - authProvider: 'google', + if (existing) { + const authProvider = existing.googleId && !existing.password ? 'google' : 'local'; + const message = + authProvider === 'google' + ? 'Bu Gmail Google orqali avval ro‘yxatdan o‘tgan. Google bilan kiring.' + : existing.emailVerified + ? 'Bu Gmail bilan avval ro‘yxatdan o‘tilgan. Kirish bo‘limidan foydalaning.' + : 'Bu Gmail bilan ro‘yxatdan o‘tilgan, lekin email hali tasdiqlanmagan. Kodni qayta yuboring.'; + + return error(res, message, 409, { + authProvider, + requiresVerification: authProvider === 'local' && !existing.emailVerified, + email: existing.email, }); } const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS); - const user = existing - ? await prisma.user.update({ - where: { id: existing.id }, - data: { - name, - password: hashedPassword, - authProvider: AuthProvider.LOCAL, - emailVerified: false, - emailVerifiedAt: null, - }, - }) - : await prisma.user.create({ - data: { - name, - email: normalizedEmail, - password: hashedPassword, - authProvider: AuthProvider.LOCAL, - emailVerified: false, - }, - }); + const user = await prisma.user.create({ + data: { + name, + email: normalizedEmail, + password: hashedPassword, + authProvider: AuthProvider.LOCAL, + emailVerified: false, + }, + }); const codeResult = await issueAuthCode({ user, type: AuthCodeType.EMAIL_VERIFICATION }); @@ -98,9 +92,12 @@ async function register(req, res) { delivery: codeResult.delivery, ...(codeResult.devCode ? { devCode: codeResult.devCode } : {}), }, - existing ? 200 : 201 + 201 ); } catch (err) { + if (err.code === 'P2002') { + return error(res, 'Bu Gmail bilan avval ro‘yxatdan o‘tilgan. Kirish bo‘limidan foydalaning.', 409); + } return error(res, err.message, 500); } } @@ -425,28 +422,155 @@ async function updatePreferences(req, res) { } } +async function verifyCurrentPassword(user, password) { + if (!user.password) return true; + if (!password) return false; + return bcrypt.compare(password, user.password); +} + +function securityRequestLimit(res, message) { + return error(res, message, 429, { + contactAdmin: true, + attemptsRemaining: 0, + }); +} + +async function requestEmailChange(req, res) { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) return error(res, 'Foydalanuvchi topilmadi.', 404); + + const newEmail = normalizeEmail(req.body.newEmail); + if (newEmail === user.email) return error(res, 'Yangi email joriy emaildan farq qilishi kerak.', 400); + + const emailOwner = await prisma.user.findFirst({ + where: { OR: [{ email: newEmail }, { pendingEmail: newEmail }], NOT: { id: user.id } }, + select: { id: true }, + }); + if (emailOwner) return error(res, 'Bu Gmail boshqa akkauntda ishlatilgan.', 409); + + const passwordOk = await verifyCurrentPassword(user, req.body.password); + if (!passwordOk) { + return error(res, user.password ? 'Parol noto‘g‘ri yoki kiritilmagan.' : 'Tasdiqlash amalga oshmadi.', 401, { + requiresPassword: Boolean(user.password), + }); + } + + const sameRequest = user.pendingEmail === newEmail; + const requestCount = sameRequest ? user.emailChangeResendCount : 0; + if (requestCount >= MAX_SECURITY_CODE_REQUESTS) { + return securityRequestLimit(res, 'Kod 3 marta so‘raldi. Emailni almashtirish uchun adminga murojaat qiling.'); + } + + const codeResult = await issueAuthCode({ user, type: AuthCodeType.EMAIL_CHANGE, newEmail }); + const nextCount = requestCount + 1; + await prisma.user.update({ + where: { id: user.id }, + data: { + pendingEmail: newEmail, + emailChangeResendCount: nextCount, + emailChangeRequestedAt: new Date(), + }, + }); + + return success(res, { + message: `Tasdiqlash kodi eski emailingizga yuborildi: ${user.email}`, + currentEmail: user.email, + pendingEmail: newEmail, + attemptsRemaining: MAX_SECURITY_CODE_REQUESTS - nextCount, + delivery: codeResult.delivery, + ...(codeResult.devCode ? { devCode: codeResult.devCode } : {}), + }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function verifyEmailChange(req, res) { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) return error(res, 'Foydalanuvchi topilmadi.', 404); + if (!user.pendingEmail) return error(res, 'Email almashtirish so‘rovi topilmadi.', 400); + + try { + await consumeAuthCode({ userId: user.id, type: AuthCodeType.EMAIL_CHANGE, code: req.body.code }); + } catch (err) { + return mapCodeError(res, err); + } + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + email: user.pendingEmail, + pendingEmail: null, + emailVerified: true, + emailVerifiedAt: new Date(), + emailChangeResendCount: 0, + emailChangeRequestedAt: null, + }, + }); + + return success(res, { + message: 'Email muvaffaqiyatli almashtirildi.', + ...createAuthPayload(updatedUser), + }); + } catch (err) { + if (err.code === 'P2002') return error(res, 'Bu Gmail boshqa akkauntda ishlatilgan.', 409); + return error(res, err.message, 500); + } +} + +async function requestAccountDeletion(req, res) { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) return error(res, 'Foydalanuvchi topilmadi.', 404); + + const passwordOk = await verifyCurrentPassword(user, req.body.password); + if (!passwordOk) { + return error(res, user.password ? 'Hisobni o‘chirish uchun parol noto‘g‘ri yoki kiritilmagan.' : 'Tasdiqlash amalga oshmadi.', 401, { + requiresPassword: Boolean(user.password), + }); + } + + if (user.accountDeleteResendCount >= MAX_SECURITY_CODE_REQUESTS) { + return securityRequestLimit(res, 'Kod 3 marta so‘raldi. Hisobni o‘chirish uchun adminga murojaat qiling.'); + } + + const codeResult = await issueAuthCode({ user, type: AuthCodeType.ACCOUNT_DELETE }); + const nextCount = user.accountDeleteResendCount + 1; + await prisma.user.update({ + where: { id: user.id }, + data: { + accountDeleteResendCount: nextCount, + accountDeleteRequestedAt: new Date(), + }, + }); + + return success(res, { + message: `Hisobni o‘chirish kodi ${user.email} manziliga yuborildi.`, + email: user.email, + attemptsRemaining: MAX_SECURITY_CODE_REQUESTS - nextCount, + delivery: codeResult.delivery, + ...(codeResult.devCode ? { devCode: codeResult.devCode } : {}), + }); + } catch (err) { + return error(res, err.message, 500); + } +} + async function deleteAccount(req, res) { try { - const { password } = req.body || {}; + const { code } = req.body || {}; const user = await prisma.user.findUnique({ where: { id: req.user.id } }); if (!user) { return error(res, 'Foydalanuvchi topilmadi.', 404); } - if (user.password) { - if (!password) { - return error(res, 'Hisobni o\'chirish uchun parolni kiriting.', 400, { - requiresPassword: true, - }); - } - - const validPassword = await bcrypt.compare(password, user.password); - if (!validPassword) { - return error(res, 'Parol noto\'g\'ri.', 401, { - requiresPassword: true, - }); - } + try { + await consumeAuthCode({ userId: user.id, type: AuthCodeType.ACCOUNT_DELETE, code }); + } catch (err) { + return mapCodeError(res, err); } await prisma.user.delete({ @@ -473,5 +597,8 @@ module.exports = { getMe, getPreferences, updatePreferences, + requestEmailChange, + verifyEmailChange, + requestAccountDeletion, deleteAccount, }; diff --git a/backend/src/controllers/bookings.controller.js b/backend/src/controllers/bookings.controller.js index ffd52cb..a9a5b72 100644 --- a/backend/src/controllers/bookings.controller.js +++ b/backend/src/controllers/bookings.controller.js @@ -15,11 +15,16 @@ function formatBooking(booking) { travelDate: booking.travelDate, message: booking.message, status: booking.status, + responseDeadlineAt: booking.responseDeadlineAt, totalEstimate: booking.totalEstimate, currency: booking.currency, source: booking.source, agencyNote: booking.agencyNote, adminNote: booking.adminNote, + confirmedAt: booking.confirmedAt, + rejectedAt: booking.rejectedAt, + cancelledAt: booking.cancelledAt, + completedAt: booking.completedAt, createdAt: booking.createdAt, updatedAt: booking.updatedAt, tour: booking.tour @@ -32,6 +37,7 @@ function formatBooking(booking) { price: booking.tour.price, priceMin: booking.tour.priceMin, imageUrl: booking.tour.imageUrl, + responseTimeMinutes: booking.tour.responseTimeMinutes ?? 45, } : null, agency: booking.agency @@ -42,6 +48,7 @@ function formatBooking(booking) { city: booking.agency.city, phone: booking.agency.phone, website: booking.agency.website, + imageUrl: booking.agency.imageUrl, } : null, }; @@ -64,7 +71,16 @@ async function create(req, res) { if (!tour) return error(res, 'Tour topilmadi yoki hali public emas', 404); const userId = req.user?.id || null; + if (userId) { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); + if (user && user.email.toLowerCase() !== input.customerEmail.toLowerCase()) { + return error(res, 'Booking emaili akkauntingiz emailiga mos bo‘lishi kerak', 400); + } + } const totalEstimate = tour.priceMin ? tour.priceMin * input.travelers : null; + const responseDeadlineAt = new Date( + Date.now() + Math.max(5, Number(tour.responseTimeMinutes || 45)) * 60 * 1000 + ); const booking = await prisma.tourBooking.create({ data: { tourId: tour.id, @@ -80,6 +96,7 @@ async function create(req, res) { currency: 'USD', source: input.source || 'mobile', status: 'pending', + responseDeadlineAt, }, include: { tour: true, agency: true }, }); @@ -92,12 +109,22 @@ async function create(req, res) { async function listMine(req, res) { try { - const email = String(req.query.email || '').trim().toLowerCase(); - const where = req.user?.id ? { userId: req.user.id } : email ? { customerEmail: email } : null; - if (!where) return error(res, 'Token yoki email talab qilinadi', 401); + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { email: true }, + }); + if (!user) return error(res, 'Foydalanuvchi topilmadi', 404); + + await prisma.tourBooking.updateMany({ + where: { + userId: null, + customerEmail: user.email.toLowerCase(), + }, + data: { userId: req.user.id }, + }); const items = await prisma.tourBooking.findMany({ - where, + where: { userId: req.user.id }, include: { tour: true, agency: true }, orderBy: { createdAt: 'desc' }, take: 100, diff --git a/backend/src/controllers/home.controller.js b/backend/src/controllers/home.controller.js index e14f2c7..6f104b7 100644 --- a/backend/src/controllers/home.controller.js +++ b/backend/src/controllers/home.controller.js @@ -150,6 +150,7 @@ function formatTour(item) { subtitle: item.subtitle, description: item.description || '', duration: item.duration, + responseTimeMinutes: item.responseTimeMinutes ?? 45, price: item.price || '', priceMin: item.priceMin ?? null, rating: item.rating ?? 0, @@ -164,6 +165,9 @@ function formatTour(item) { name: item.agency.name, city: item.agency.city, rating: item.agency.rating, + phone: item.agency.phone, + website: item.agency.website, + imageUrl: item.agency.imageUrl, } : null, source: item.source || 'admin', @@ -533,7 +537,7 @@ async function recordInteraction(req, res) { } const sessionId = req.body?.sessionId ? String(req.body.sessionId).slice(0, 120) : null; - const userId = req.user?.id || (req.body?.userId ? String(req.body.userId).slice(0, 120) : null); + const userId = req.user?.id || null; const source = req.body?.source ? String(req.body.source).slice(0, 80) : 'website'; const metadata = req.body?.metadata && typeof req.body.metadata === 'object' ? req.body.metadata : undefined; diff --git a/backend/src/middleware/adminAuth.middleware.js b/backend/src/middleware/adminAuth.middleware.js index 86c5bcf..d96671d 100644 --- a/backend/src/middleware/adminAuth.middleware.js +++ b/backend/src/middleware/adminAuth.middleware.js @@ -1,9 +1,16 @@ +const crypto = require('crypto'); const { error } = require('../utils/response'); function adminAuthMiddleware(req, res, next) { const key = req.headers['x-admin-key']; const expectedKey = process.env.ADMIN_SECRET_KEY || (process.env.NODE_ENV !== 'production' ? 'change_me' : ''); - if (!key || !expectedKey || key !== expectedKey) { + const keyBuffer = Buffer.from(String(key || '')); + const expectedBuffer = Buffer.from(expectedKey); + const valid = + keyBuffer.length === expectedBuffer.length && + expectedBuffer.length > 0 && + crypto.timingSafeEqual(keyBuffer, expectedBuffer); + if (!valid) { return error(res, 'Forbidden', 403); } next(); diff --git a/backend/src/middleware/rateLimit.middleware.js b/backend/src/middleware/rateLimit.middleware.js index 752a6fc..71e16e4 100644 --- a/backend/src/middleware/rateLimit.middleware.js +++ b/backend/src/middleware/rateLimit.middleware.js @@ -1,38 +1,58 @@ const rateLimit = require('express-rate-limit'); -// ─── Authenticated user ID extractor ───────────────────────────────────────── -// When a valid Bearer token is present we rate-limit by userId, not by IP. -// This means multiple users sharing the same IP (mobile carrier NAT, office -// Wi-Fi, etc.) each get their own independent quota. -function keyGenerator(req) { - // req.user is populated by auth middleware when the token is valid - if (req.user && req.user.id) { - return `user:${req.user.id}`; - } - // Fall back to IP for unauthenticated requests - return req.ip || req.socket?.remoteAddress || 'unknown'; -} - -// ─── Global limiter — all /api/v1/* routes ──────────────────────────────────── -// 500 requests per 15 minutes per user/IP. This is generous enough that -// normal app usage (home + explore + trips + planner) never hits the ceiling, -// while still protecting against abuse. const rateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes + windowMs: 15 * 60 * 1000, max: 500, standardHeaders: true, legacyHeaders: false, - keyGenerator, message: { success: false, message: 'Juda ko\'p so\'rovlar. Biroz kuting va qayta urinib ko\'ring.' }, }); -// ─── Planner limiter — POST /api/v1/planner/generate ───────────────────────── -// AI generation is expensive; limit to 20 requests per minute per user/IP. const plannerLimiter = rateLimit({ - windowMs: 60 * 1000, // 1 minute + windowMs: 60 * 1000, max: 20, - keyGenerator, + standardHeaders: true, + legacyHeaders: false, message: { success: false, message: 'Reja tuzish limitiga yetdingiz. Biroz kuting.' }, }); -module.exports = { rateLimiter, plannerLimiter }; +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, message: 'Kirish urinishlari limiti tugadi. Keyinroq qayta urinib ko\'ring.' }, +}); + +const securityCodeLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, message: 'Kod so\'rash limiti tugadi. Keyinroq qayta urinib ko\'ring.' }, +}); + +const bookingLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, message: 'Bron qilish limiti tugadi. Keyinroq qayta urinib ko\'ring.' }, +}); + +const interactionLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, message: 'Interaction limiti tugadi. Biroz kuting.' }, +}); + +module.exports = { + rateLimiter, + plannerLimiter, + authLimiter, + securityCodeLimiter, + bookingLimiter, + interactionLimiter, +}; diff --git a/backend/src/routes/agency.routes.js b/backend/src/routes/agency.routes.js index 02b2283..905e389 100644 --- a/backend/src/routes/agency.routes.js +++ b/backend/src/routes/agency.routes.js @@ -1,14 +1,19 @@ const router = require('express').Router(); const agency = require('../controllers/agency.controller'); const { agencyAuthMiddleware } = require('../middleware/agencyAuth.middleware'); +const { authLimiter, securityCodeLimiter } = require('../middleware/rateLimit.middleware'); -router.post('/auth/register', agency.register); -router.post('/auth/verify-email', agency.verifyEmail); -router.post('/auth/login', agency.login); +router.post('/auth/register', authLimiter, agency.register); +router.post('/auth/verify-email', authLimiter, agency.verifyEmail); +router.post('/auth/login', authLimiter, agency.login); +router.post('/auth/google', authLimiter, agency.googleAuth); router.use(agencyAuthMiddleware); router.get('/auth/me', agency.me); +router.post('/auth/email-change/request', securityCodeLimiter, agency.requestEmailChange); +router.post('/auth/email-change/resend', securityCodeLimiter, agency.resendEmailChange); +router.post('/auth/email-change/confirm', securityCodeLimiter, agency.confirmEmailChange); router.get('/application', agency.getApplication); router.put('/application', agency.upsertApplication); router.post('/application/submit', agency.submitApplication); diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index bfaf2b6..ca7ebf5 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -11,9 +11,13 @@ const { getMe, getPreferences, updatePreferences, + requestEmailChange, + verifyEmailChange, + requestAccountDeletion, deleteAccount, } = require('../controllers/auth.controller'); const { authMiddleware } = require('../middleware/auth.middleware'); +const { authLimiter, securityCodeLimiter } = require('../middleware/rateLimit.middleware'); const { validate } = require('../middleware/validate.middleware'); const { registerSchema, @@ -26,19 +30,25 @@ const { profileSchema, preferencesSchema, deleteAccountSchema, + requestEmailChangeSchema, + verifyEmailChangeSchema, + requestAccountDeletionSchema, } = require('../schemas/auth.schema'); -router.post('/register', validate(registerSchema), register); -router.post('/verify-email', validate(verifyEmailSchema), verifyEmail); -router.post('/resend-verification', validate(resendVerificationSchema), resendVerification); -router.post('/login', validate(loginSchema), login); -router.post('/forgot-password', validate(forgotPasswordSchema), forgotPassword); -router.post('/reset-password', validate(resetPasswordSchema), resetPassword); -router.post('/google', validate(googleAuthSchema), googleAuth); +router.post('/register', authLimiter, validate(registerSchema), register); +router.post('/verify-email', authLimiter, validate(verifyEmailSchema), verifyEmail); +router.post('/resend-verification', securityCodeLimiter, validate(resendVerificationSchema), resendVerification); +router.post('/login', authLimiter, validate(loginSchema), login); +router.post('/forgot-password', securityCodeLimiter, validate(forgotPasswordSchema), forgotPassword); +router.post('/reset-password', authLimiter, validate(resetPasswordSchema), resetPassword); +router.post('/google', authLimiter, validate(googleAuthSchema), googleAuth); router.get('/me', authMiddleware, getMe); router.put('/profile', authMiddleware, validate(profileSchema), updateProfile); router.get('/preferences', authMiddleware, getPreferences); router.put('/preferences', authMiddleware, validate(preferencesSchema), updatePreferences); -router.delete('/account', authMiddleware, validate(deleteAccountSchema), deleteAccount); +router.post('/email-change/request', securityCodeLimiter, authMiddleware, validate(requestEmailChangeSchema), requestEmailChange); +router.post('/email-change/verify', securityCodeLimiter, authMiddleware, validate(verifyEmailChangeSchema), verifyEmailChange); +router.post('/account-deletion/request', securityCodeLimiter, authMiddleware, validate(requestAccountDeletionSchema), requestAccountDeletion); +router.delete('/account', securityCodeLimiter, authMiddleware, validate(deleteAccountSchema), deleteAccount); module.exports = router; diff --git a/backend/src/routes/bookings.routes.js b/backend/src/routes/bookings.routes.js index 0254be4..01f0985 100644 --- a/backend/src/routes/bookings.routes.js +++ b/backend/src/routes/bookings.routes.js @@ -1,8 +1,9 @@ const router = require('express').Router(); const bookings = require('../controllers/bookings.controller'); -const { optionalAuth } = require('../middleware/auth.middleware'); +const { authMiddleware, optionalAuth } = require('../middleware/auth.middleware'); +const { bookingLimiter } = require('../middleware/rateLimit.middleware'); -router.post('/', optionalAuth, bookings.create); -router.get('/mine', optionalAuth, bookings.listMine); +router.post('/', bookingLimiter, optionalAuth, bookings.create); +router.get('/mine', authMiddleware, bookings.listMine); module.exports = router; diff --git a/backend/src/routes/home.routes.js b/backend/src/routes/home.routes.js index 5780f50..78580b4 100644 --- a/backend/src/routes/home.routes.js +++ b/backend/src/routes/home.routes.js @@ -8,6 +8,8 @@ const { getTours, recordInteraction, } = require('../controllers/home.controller'); +const { optionalAuth } = require('../middleware/auth.middleware'); +const { interactionLimiter } = require('../middleware/rateLimit.middleware'); router.get('/', getHome); router.get('/hero-slides', getHeroSlides); @@ -15,6 +17,6 @@ router.get('/places', getPlaces); router.get('/tours', getTours); router.get('/agencies', getAgencies); router.get('/stories', getStories); -router.post('/interactions', recordInteraction); +router.post('/interactions', interactionLimiter, optionalAuth, recordInteraction); module.exports = router; diff --git a/backend/src/routes/poi.routes.js b/backend/src/routes/poi.routes.js index 05167ee..08aac4f 100644 --- a/backend/src/routes/poi.routes.js +++ b/backend/src/routes/poi.routes.js @@ -1,11 +1,12 @@ const router = require('express').Router(); const { getAll, getOne, create, update, remove, bulkCreate } = require('../controllers/poi.controller'); +const { adminAuthMiddleware } = require('../middleware/adminAuth.middleware'); router.get('/', getAll); router.get('/:id', getOne); -router.post('/', create); -router.post('/bulk', bulkCreate); -router.put('/:id', update); -router.delete('/:id', remove); +router.post('/', adminAuthMiddleware, create); +router.post('/bulk', adminAuthMiddleware, bulkCreate); +router.put('/:id', adminAuthMiddleware, update); +router.delete('/:id', adminAuthMiddleware, remove); module.exports = router; diff --git a/backend/src/schemas/agency.schema.js b/backend/src/schemas/agency.schema.js index 4ffe082..7f31834 100644 --- a/backend/src/schemas/agency.schema.js +++ b/backend/src/schemas/agency.schema.js @@ -8,6 +8,22 @@ const nullableUrl = z .or(z.literal('')) .transform((value) => value || undefined); +const imageValue = z + .string() + .trim() + .max(8_000_000) + .refine( + (value) => + !value || + /^https?:\/\//i.test(value) || + /^\/uploads\//i.test(value) || + /^data:image\/(?:jpeg|png|webp|gif);base64,/i.test(value), + 'Rasm URL yoki JPG/PNG/WEBP/GIF fayl bo‘lishi kerak' + ) + .optional() + .or(z.literal('')) + .transform((value) => value || undefined); + const registerSchema = z.object({ email: z.string().trim().email(), password: z.string().min(8), @@ -18,11 +34,23 @@ const verifyEmailSchema = z.object({ code: z.string().trim().min(4).max(10), }); +const emailChangeRequestSchema = z.object({ + newEmail: z.string().trim().email(), +}); + +const emailChangeConfirmSchema = z.object({ + code: z.string().trim().length(6), +}); + const loginSchema = z.object({ email: z.string().trim().email(), password: z.string().min(1), }); +const googleAuthSchema = z.object({ + idToken: z.string().trim().min(20), +}); + const applicationSchema = z.object({ companyName: z.string().trim().min(2), legalName: z.string().trim().optional().or(z.literal('')), @@ -36,6 +64,7 @@ const applicationSchema = z.object({ instagram: z.string().trim().optional().or(z.literal('')), serviceTypes: z.array(z.string().trim().min(2)).min(1).max(12), description: z.string().trim().min(20), + imageUrl: imageValue, documents: z.any().optional(), }); @@ -45,10 +74,11 @@ const tourSchema = z.object({ subtitle: z.string().trim().min(3), description: z.string().trim().optional().or(z.literal('')), duration: z.string().trim().min(2), + responseTimeMinutes: z.coerce.number().int().min(5).max(1440).default(45), price: z.string().trim().optional().or(z.literal('')), priceMin: z.coerce.number().int().nonnegative().optional().nullable(), badge: z.string().trim().optional().default('Latest'), - imageUrl: nullableUrl, + imageUrl: imageValue, itinerary: z.any().optional(), highlights: z.array(z.string().trim().min(2)).max(20).optional().default([]), }); @@ -60,7 +90,10 @@ const adminReviewSchema = z.object({ module.exports = { registerSchema, verifyEmailSchema, + emailChangeRequestSchema, + emailChangeConfirmSchema, loginSchema, + googleAuthSchema, applicationSchema, tourSchema, adminReviewSchema, diff --git a/backend/src/schemas/auth.schema.js b/backend/src/schemas/auth.schema.js index b891b8c..5fd26be 100644 --- a/backend/src/schemas/auth.schema.js +++ b/backend/src/schemas/auth.schema.js @@ -59,6 +59,19 @@ const preferencesSchema = z.object({ const deleteAccountSchema = z.object({ confirm: z.literal(true), + code: codeSchema, +}); + +const requestEmailChangeSchema = z.object({ + newEmail: emailSchema, + password: z.string().min(1, 'Parol talab qilinadi').optional(), +}); + +const verifyEmailChangeSchema = z.object({ + code: codeSchema, +}); + +const requestAccountDeletionSchema = z.object({ password: z.string().min(1, 'Parol talab qilinadi').optional(), }); @@ -73,4 +86,7 @@ module.exports = { profileSchema, preferencesSchema, deleteAccountSchema, + requestEmailChangeSchema, + verifyEmailChangeSchema, + requestAccountDeletionSchema, }; diff --git a/backend/src/services/auth.service.js b/backend/src/services/auth.service.js index 854a23b..74f9d15 100644 --- a/backend/src/services/auth.service.js +++ b/backend/src/services/auth.service.js @@ -1,11 +1,18 @@ const crypto = require('crypto'); const axios = require('axios'); const { prisma } = require('../config/database'); -const { sendPasswordResetCodeEmail, sendVerificationCodeEmail } = require('./email.service'); +const { + sendAccountDeleteCodeEmail, + sendEmailChangeCodeEmail, + sendPasswordResetCodeEmail, + sendVerificationCodeEmail, +} = require('./email.service'); const AuthCodeType = { EMAIL_VERIFICATION: 'EMAIL_VERIFICATION', PASSWORD_RESET: 'PASSWORD_RESET', + EMAIL_CHANGE: 'EMAIL_CHANGE', + ACCOUNT_DELETE: 'ACCOUNT_DELETE', }; const AuthProvider = { @@ -37,14 +44,14 @@ function buildPublicUser(user) { } function generateNumericCode() { - return String(Math.floor(100000 + Math.random() * 900000)); + return String(crypto.randomInt(100000, 1000000)); } function hashCode(code) { return crypto.createHash('sha256').update(code).digest('hex'); } -async function issueAuthCode({ user, type }) { +async function issueAuthCode({ user, type, newEmail }) { const code = generateNumericCode(); const codeHash = hashCode(code); const ttlMinutes = type === AuthCodeType.EMAIL_VERIFICATION ? EMAIL_VERIFICATION_TTL_MINUTES : PASSWORD_RESET_TTL_MINUTES; @@ -63,20 +70,22 @@ async function issueAuthCode({ user, type }) { }, }); - const emailResult = - type === AuthCodeType.EMAIL_VERIFICATION - ? await sendVerificationCodeEmail({ - email: user.email, - name: user.name, - code, - expiresInMinutes: ttlMinutes, - }) - : await sendPasswordResetCodeEmail({ - email: user.email, - name: user.name, - code, - expiresInMinutes: ttlMinutes, - }); + const mailPayload = { + email: user.email, + name: user.name, + code, + expiresInMinutes: ttlMinutes, + }; + let emailResult; + if (type === AuthCodeType.EMAIL_VERIFICATION) { + emailResult = await sendVerificationCodeEmail(mailPayload); + } else if (type === AuthCodeType.EMAIL_CHANGE) { + emailResult = await sendEmailChangeCodeEmail({ ...mailPayload, newEmail }); + } else if (type === AuthCodeType.ACCOUNT_DELETE) { + emailResult = await sendAccountDeleteCodeEmail(mailPayload); + } else { + emailResult = await sendPasswordResetCodeEmail(mailPayload); + } return { ...emailResult, @@ -102,7 +111,12 @@ async function consumeAuthCode({ userId, type, code }) { throw new Error('CODE_EXPIRED'); } - if (authCode.codeHash !== hashCode(code)) { + const expectedHash = Buffer.from(authCode.codeHash, 'hex'); + const actualHash = Buffer.from(hashCode(code), 'hex'); + if ( + expectedHash.length !== actualHash.length || + !crypto.timingSafeEqual(expectedHash, actualHash) + ) { throw new Error('CODE_INVALID'); } diff --git a/backend/src/services/email.service.js b/backend/src/services/email.service.js index d330db3..6f6ddb2 100644 --- a/backend/src/services/email.service.js +++ b/backend/src/services/email.service.js @@ -117,6 +117,22 @@ async function sendVerificationCodeEmail({ email, name, code, expiresInMinutes } }); } +async function sendEmailChangeCodeEmail({ email, name, code, expiresInMinutes, newEmail }) { + return sendMail({ + to: email, + subject: `${APP_NAME} email almashtirish kodi`, + html: buildHtml({ + heading: 'Email manzilini almashtirish', + intro: `${name || 'Salom'}, akkauntingiz emailini ${safeText(newEmail)} manziliga almashtirish uchun quyidagi kodni kiriting.`, + code, + expiresInMinutes, + footer: "Agar bu so'rovni siz yubormagan bo'lsangiz, kodni hech kimga bermang va support bilan bog'laning.", + }), + text: `Email almashtirish kodi: ${code}. Yangi email: ${safeText(newEmail)}. Kod ${expiresInMinutes} daqiqa amal qiladi.`, + logMeta: { type: 'agency_email_change', code, email, newEmail: safeText(newEmail) }, + }); +} + async function sendPasswordResetCodeEmail({ email, name, code, expiresInMinutes }) { return sendMail({ to: email, @@ -133,6 +149,22 @@ async function sendPasswordResetCodeEmail({ email, name, code, expiresInMinutes }); } +async function sendAccountDeleteCodeEmail({ email, name, code, expiresInMinutes }) { + return sendMail({ + to: email, + subject: `${APP_NAME} hisobni o'chirish kodi`, + html: buildHtml({ + heading: "Hisobni o'chirishni tasdiqlang", + intro: `${name || 'Salom'}, akkauntingizni butunlay o'chirish uchun quyidagi kodni kiriting.`, + code, + expiresInMinutes, + footer: "Agar bu so'rovni siz yubormagan bo'lsangiz, kodni hech kimga bermang va support bilan bog'laning.", + }), + text: `Hisobni o'chirish kodi: ${code}. Kod ${expiresInMinutes} daqiqa amal qiladi.`, + logMeta: { type: 'account_delete', code, email }, + }); +} + function safeText(value) { if (typeof value !== 'string') return ''; return value.replace(/[<>]/g, '').trim(); @@ -213,6 +245,8 @@ async function sendSupportFeedbackEmail({ module.exports = { sendVerificationCodeEmail, + sendEmailChangeCodeEmail, sendPasswordResetCodeEmail, + sendAccountDeleteCodeEmail, sendSupportFeedbackEmail, }; diff --git a/backend/src/services/planner.service.js b/backend/src/services/planner.service.js index 9fb8671..724ac38 100644 --- a/backend/src/services/planner.service.js +++ b/backend/src/services/planner.service.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const { prisma } = require('../config/database'); const { refineTripPlanWithGemini } = require('./geminiPlanner.service'); @@ -14,7 +15,7 @@ const INTEREST_KEYWORDS = { }; function genId() { - return Math.random().toString(36).slice(2) + Date.now().toString(36); + return crypto.randomUUID(); } function formatCityName(raw) { diff --git a/backend/src/utils/agencyJwt.js b/backend/src/utils/agencyJwt.js index b73e81f..8b104b8 100644 --- a/backend/src/utils/agencyJwt.js +++ b/backend/src/utils/agencyJwt.js @@ -1,15 +1,25 @@ const jwt = require('jsonwebtoken'); +const { getSecret } = require('../config/security'); -const SECRET = process.env.AGENCY_JWT_SECRET || process.env.JWT_SECRET || 'travelorai_agency_secret'; const EXPIRES_IN = process.env.AGENCY_JWT_EXPIRES_IN || '7d'; +function getAgencySecret() { + if (process.env.AGENCY_JWT_SECRET) { + return getSecret('AGENCY_JWT_SECRET', 'travelorai_agency_secret'); + } + if (process.env.JWT_SECRET) { + return getSecret('JWT_SECRET', 'travelorai_secret'); + } + return getSecret('AGENCY_JWT_SECRET', 'travelorai_agency_secret'); +} + function signAgencyToken(payload) { - return jwt.sign(payload, SECRET, { expiresIn: EXPIRES_IN }); + return jwt.sign(payload, getAgencySecret(), { expiresIn: EXPIRES_IN }); } function verifyAgencyToken(token) { try { - return jwt.verify(token, SECRET); + return jwt.verify(token, getAgencySecret()); } catch { return null; } diff --git a/backend/src/utils/dataImage.js b/backend/src/utils/dataImage.js new file mode 100644 index 0000000..d692486 --- /dev/null +++ b/backend/src/utils/dataImage.js @@ -0,0 +1,80 @@ +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +const IMAGE_TYPES = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +function hasValidSignature(buffer, mime) { + if (mime === 'image/jpeg') { + return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + } + if (mime === 'image/png') { + return buffer.length >= 8 + && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + } + if (mime === 'image/gif') { + const header = buffer.subarray(0, 6).toString('ascii'); + return header === 'GIF87a' || header === 'GIF89a'; + } + if (mime === 'image/webp') { + return buffer.length >= 12 + && buffer.subarray(0, 4).toString('ascii') === 'RIFF' + && buffer.subarray(8, 12).toString('ascii') === 'WEBP'; + } + return false; +} + +async function materializeDataImage(value, folder = 'agency') { + const text = String(value || '').trim(); + if (!text.startsWith('data:image/')) return text; + + const match = text.match(/^data:(image\/(?:jpeg|png|webp|gif));base64,([a-z0-9+/=\s]+)$/i); + if (!match) throw new Error('Rasm formati noto‘g‘ri'); + + const mime = match[1].toLowerCase(); + const extension = IMAGE_TYPES[mime]; + if (!extension) throw new Error('Faqat JPG, PNG, WEBP yoki GIF rasm qabul qilinadi'); + + const buffer = Buffer.from(match[2].replace(/\s/g, ''), 'base64'); + if (!buffer.length || buffer.length > MAX_IMAGE_BYTES) { + throw new Error('Rasm hajmi 5 MB dan oshmasligi kerak'); + } + if (!hasValidSignature(buffer, mime)) { + throw new Error('Rasm tarkibi tanlangan formatga mos emas'); + } + + const safeFolder = String(folder || 'agency').replace(/[^a-z0-9_-]/gi, '') || 'agency'; + const uploadDir = path.resolve(__dirname, '../../uploads', safeFolder); + await fs.mkdir(uploadDir, { recursive: true }); + + const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`; + await fs.writeFile(path.join(uploadDir, filename), buffer); + return `/uploads/${safeFolder}/${filename}`; +} + +async function deleteMaterializedImage(value, folder = 'agency') { + const safeFolder = String(folder || 'agency').replace(/[^a-z0-9_-]/gi, '') || 'agency'; + const text = String(value || '').trim(); + const match = text.match(new RegExp(`^/uploads/${safeFolder}/([a-z0-9._-]+)$`, 'i')); + if (!match) return false; + + const uploadDir = path.resolve(__dirname, '../../uploads', safeFolder); + const filePath = path.resolve(uploadDir, match[1]); + if (!filePath.startsWith(`${uploadDir}${path.sep}`)) return false; + + try { + await fs.unlink(filePath); + return true; + } catch (err) { + if (err.code === 'ENOENT') return false; + throw err; + } +} + +module.exports = { materializeDataImage, deleteMaterializedImage, hasValidSignature }; diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js index 01b7f08..bab6143 100644 --- a/backend/src/utils/jwt.js +++ b/backend/src/utils/jwt.js @@ -1,15 +1,15 @@ const jwt = require('jsonwebtoken'); +const { getSecret } = require('../config/security'); -const SECRET = process.env.JWT_SECRET || 'travelorai_secret'; const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; function signToken(payload) { - return jwt.sign(payload, SECRET, { expiresIn: EXPIRES_IN }); + return jwt.sign(payload, getSecret('JWT_SECRET', 'travelorai_secret'), { expiresIn: EXPIRES_IN }); } function verifyToken(token) { try { - return jwt.verify(token, SECRET); + return jwt.verify(token, getSecret('JWT_SECRET', 'travelorai_secret')); } catch { return null; } diff --git a/backend/uploads/hero/1780753328834-15b70044964b2eca29.jpg b/backend/uploads/hero/1780753328834-15b70044964b2eca29.jpg new file mode 100644 index 0000000..fddf98a Binary files /dev/null and b/backend/uploads/hero/1780753328834-15b70044964b2eca29.jpg differ diff --git a/backend/uploads/hero/1780753713974-6ab90225253ba3d3d1.jpg b/backend/uploads/hero/1780753713974-6ab90225253ba3d3d1.jpg new file mode 100644 index 0000000..8f137ff Binary files /dev/null and b/backend/uploads/hero/1780753713974-6ab90225253ba3d3d1.jpg differ diff --git a/backend/uploads/hero/1780754193334-ef7372849cfdf036be.jpg b/backend/uploads/hero/1780754193334-ef7372849cfdf036be.jpg new file mode 100644 index 0000000..0163eea Binary files /dev/null and b/backend/uploads/hero/1780754193334-ef7372849cfdf036be.jpg differ diff --git a/backend/uploads/hero/1780754199246-ef7372849cfdf036be.jpg b/backend/uploads/hero/1780754199246-ef7372849cfdf036be.jpg new file mode 100644 index 0000000..0163eea Binary files /dev/null and b/backend/uploads/hero/1780754199246-ef7372849cfdf036be.jpg differ diff --git a/backend/uploads/hero/1780754211901-ef7372849cfdf036be.jpg b/backend/uploads/hero/1780754211901-ef7372849cfdf036be.jpg new file mode 100644 index 0000000..0163eea Binary files /dev/null and b/backend/uploads/hero/1780754211901-ef7372849cfdf036be.jpg differ diff --git a/backend/uploads/hero/1780754414430-6ab90225253ba3d3d1.jpg b/backend/uploads/hero/1780754414430-6ab90225253ba3d3d1.jpg new file mode 100644 index 0000000..8f137ff Binary files /dev/null and b/backend/uploads/hero/1780754414430-6ab90225253ba3d3d1.jpg differ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index e02bb9c..3e1e62f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,10 +1,20 @@ version: '3.9' services: + postgres: + ports: + - '5433:5432' + + redis: + ports: + - '6380:6379' + backend: command: npx nodemon server.js environment: NODE_ENV: development + ports: + - '4000:4000' volumes: - ./backend:/app - /app/node_modules diff --git a/docker-compose.yml b/docker-compose.yml index 5943868..aefa8b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,11 +9,9 @@ services: environment: POSTGRES_DB: travelorai_db POSTGRES_USER: travelorai - POSTGRES_PASSWORD: travelorai_pass + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} volumes: - postgres_data:/var/lib/postgresql/data - ports: - - '5433:5432' healthcheck: test: ['CMD-SHELL', 'pg_isready -U travelorai -d travelorai_db'] interval: 10s @@ -27,8 +25,6 @@ services: command: redis-server --appendonly yes volumes: - redis_data:/data - ports: - - '6380:6379' healthcheck: test: ['CMD', 'redis-cli', 'ping'] interval: 10s @@ -43,12 +39,13 @@ services: restart: unless-stopped env_file: ./backend/.env environment: + NODE_ENV: production PORT: 4000 - DATABASE_URL: postgresql://travelorai:travelorai_pass@postgres:5432/travelorai_db + DATABASE_URL: postgresql://travelorai:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/travelorai_db REDIS_URL: redis://redis:6379 ALLOWED_ORIGINS: http://localhost,http://127.0.0.1,http://localhost:3000,http://127.0.0.1:3000,http://localhost:8081,http://localhost:19006,https://travelorai.com,https://www.travelorai.com,https://admin.travelorai.com,https://agency.travelorai.com - ports: - - '4000:4000' + expose: + - '4000' depends_on: postgres: condition: service_healthy @@ -73,9 +70,9 @@ services: NEXT_PUBLIC_API_URL: https://travelorai.com/api/v1 ADMIN_API_URL: http://backend:4000/api/v1 AGENCY_API_URL: http://backend:4000/api/v1 - ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-travelorai_admin_session_change_me} - ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} + ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required} + ADMIN_USERNAME: ${ADMIN_USERNAME:?ADMIN_USERNAME is required} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} NODE_ENV: production depends_on: - backend diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 55f4fad..3ea6731 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { }.standardOutput.asText.get().trim() ).getParentFile().absolutePath includeBuild(reactNativeGradlePlugin) - + def expoPluginsPath = new File( providers.exec { workingDir(rootDir) diff --git a/mobile/app.config.js b/mobile/app.config.js index 582fad8..03f60fd 100644 --- a/mobile/app.config.js +++ b/mobile/app.config.js @@ -4,25 +4,21 @@ try { // Expo can load env files itself; this keeps config usable if dotenv is not hoisted. } -const appJson = require('./app.json'); +module.exports = ({ config }) => { + const yandexMapkitApiKey = + process.env.EXPO_PUBLIC_YANDEX_MAPKIT_API_KEY || process.env.YANDEX_MAPKIT_API_KEY || ''; + const yandexStaticMapsApiKey = + process.env.EXPO_PUBLIC_YANDEX_STATIC_MAPS_API_KEY || process.env.YANDEX_STATIC_MAPS_API_KEY || yandexMapkitApiKey; + const yandexRedirectEnabled = + process.env.EXPO_PUBLIC_YANDEX_REDIRECT_ENABLED !== 'false'; -const plugins = [...(appJson.expo.plugins || [])]; -const yandexMapkitApiKey = - process.env.EXPO_PUBLIC_YANDEX_MAPKIT_API_KEY || process.env.YANDEX_MAPKIT_API_KEY || ''; -const yandexStaticMapsApiKey = - process.env.EXPO_PUBLIC_YANDEX_STATIC_MAPS_API_KEY || process.env.YANDEX_STATIC_MAPS_API_KEY || yandexMapkitApiKey; -const yandexRedirectEnabled = - process.env.EXPO_PUBLIC_YANDEX_REDIRECT_ENABLED !== 'false'; - -module.exports = { - expo: { - ...appJson.expo, - plugins, + return { + ...config, extra: { - ...(appJson.expo.extra || {}), + ...(config.extra || {}), yandexMapkitApiKey, yandexStaticMapsApiKey, yandexRedirectEnabled, }, - }, + }; }; diff --git a/mobile/app/(tabs)/explore.tsx b/mobile/app/(tabs)/explore.tsx index 7350018..5f180fb 100644 --- a/mobile/app/(tabs)/explore.tsx +++ b/mobile/app/(tabs)/explore.tsx @@ -19,7 +19,6 @@ import { import * as Location from 'expo-location'; import { Ionicons } from '@expo/vector-icons'; -import { Search } from '@metamorph/react-native-yamap'; import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; @@ -35,6 +34,7 @@ import { extractApiData } from '../../src/utils/auth'; import { poiAPI, transportAPI, tripsAPI, yandexAPI, type PoiPayload, type YandexPlacePointPayload, type YandexTransportPointPayload } from '../../src/utils/api'; import { getItem, getJSON, getUserKey, KEYS, saveItem, saveJSON } from '../../src/utils/storage'; import type { TripPlan } from '../../src/utils/tripPlanner'; +import { isYandexSearchAvailable, searchYandexText } from '../../src/utils/yandexSearch'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -815,7 +815,7 @@ async function fetchNativeYandexSearchPoints({ subtype?: string; limit: number; }): Promise { - if (Platform.OS === 'web' || !Search?.searchText) return []; + if (!isYandexSearchAvailable()) return []; const queries = nativeQueriesFor(type, subtype).slice(0, 6); const searchOrigins = (origins && origins.length > 0 ? origins : [origin]).slice(0, 5); @@ -832,7 +832,7 @@ async function fetchNativeYandexSearchPoints({ type: 'POINT', value: { lat: searchOrigin.latitude, lon: searchOrigin.longitude }, } as any; - const items = await Search.searchText(query, figure, options); + const items = await searchYandexText(query, figure, options); const list = Array.isArray(items) ? items : items ? [items] : []; return list .map((item) => mapNativeYandexSearchPoint(item, query, type)) @@ -857,7 +857,7 @@ async function fetchNativeYandexTextSearchPoints({ type?: 'landmark' | 'restaurant' | 'hotel' | 'transport'; limit: number; }): Promise { - if (Platform.OS === 'web' || !Search?.searchText || query.trim().length < 2) return []; + if (!isYandexSearchAvailable() || query.trim().length < 2) return []; const fallbackType = type || 'landmark'; const figure = { @@ -871,7 +871,7 @@ async function fetchNativeYandexTextSearchPoints({ } as any; try { - const items = await Search.searchText(query, figure, options); + const items = await searchYandexText(query, figure, options); const list = Array.isArray(items) ? items : items ? [items] : []; const mapped = list .map((item) => mapNativeYandexSearchPoint(item, query, fallbackType)) @@ -1028,7 +1028,8 @@ export default function ExploreScreen() { const insets = useSafeAreaInsets(); const safeBottom = Math.max(insets.bottom, 22); const styles = useMemo(() => createStyles(colors), [colors]); - const { tripId } = useLocalSearchParams<{ tripId?: string }>(); + const { tripId, q: routeQuery, category: routeCategory, radius: routeRadius } = + useLocalSearchParams<{ tripId?: string; q?: string; category?: string; radius?: string }>(); const tt = useCallback( (key: string, fallback: string, values?: Record) => @@ -1072,6 +1073,18 @@ export default function ExploreScreen() { const [savingStopId, setSavingStopId] = useState(null); const [listVersion, setListVersion] = useState(0); + useEffect(() => { + if (typeof routeQuery === 'string') setQuery(routeQuery); + if (typeof routeCategory === 'string' && ['all', 'restaurant', 'hotel', 'landmark', 'transport'].includes(routeCategory)) { + setCategory(routeCategory as CategoryFilter); + } + const nextRadius = Number(routeRadius); + if (RADIUS_OPTIONS.includes(nextRadius)) { + setRadiusKm(nextRadius); + setLoadMode('radius'); + } + }, [routeCategory, routeQuery, routeRadius]); + const subchipsAnim = useRef(new Animated.Value(0)).current; const locationWatcherRef = useRef(null); const lastPlacesRequestKeyRef = useRef(null); diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 7cbf569..d85c8e8 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -20,6 +20,7 @@ import { type AppColors, useAppTheme } from '../../src/theme/app-theme'; import { extractApiData, getUserDisplayName, type AuthUser } from '../../src/utils/auth'; import { homeAPI } from '../../src/utils/api'; import { KEYS, getJSON, saveJSON } from '../../src/utils/storage'; +import { useWishlist } from '../../src/hooks/useWishlist'; import { HOME_DEFAULT_HERO, PLACE_FILTERS, @@ -67,6 +68,7 @@ export default function HomeScreen() { const [placeFilter, setPlaceFilter] = useState('all'); const [heroIndex, setHeroIndex] = useState(0); const [user, setUser] = useState(null); + const { isWishlisted, toggle: toggleWishlist } = useWishlist(user?.id || null); const loadHomeData = useCallback(async () => { const [savedUser, cached] = await Promise.all([ @@ -202,8 +204,23 @@ export default function HomeScreen() { openPlace(item)}> - - + { + event.stopPropagation(); + void toggleWishlist({ + id: item.id, + poiId: item.id, + name: item.name, + city: item.city || 'Global', + slug: item.slug, + type: item.type, + icon: '📍', + }); + }} + > + {item.name} diff --git a/mobile/app/(tabs)/profile.tsx b/mobile/app/(tabs)/profile.tsx index 1c49fd6..8a7b71a 100644 --- a/mobile/app/(tabs)/profile.tsx +++ b/mobile/app/(tabs)/profile.tsx @@ -23,7 +23,7 @@ import { type AppColors, type ThemePreference, useAppTheme } from '../../src/the import { useAchievements } from '../../src/hooks/useAchievements'; import { useTrips } from '../../src/hooks/useTrips'; import { useWishlist } from '../../src/hooks/useWishlist'; -import { authAPI } from '../../src/utils/api'; +import { ApiError, authAPI, type SecurityCodePayload } from '../../src/utils/api'; import { type AuthUser, getUserDisplayName, getUserInitials } from '../../src/utils/auth'; import { extractApiData } from '../../src/utils/auth'; import { KEYS, clearAll, clearAuthSession, getItem, getJSON, getUserKey, saveItem, saveUserProfile } from '../../src/utils/storage'; @@ -56,6 +56,9 @@ export default function ProfileScreen() { const [showLangPicker, setShowLangPicker] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deletePassword, setDeletePassword] = useState(''); + const [deleteCode, setDeleteCode] = useState(''); + const [deleteStage, setDeleteStage] = useState<'request' | 'verify'>('request'); + const [deleteAttemptsRemaining, setDeleteAttemptsRemaining] = useState(3); const [isDeletingAccount, setIsDeletingAccount] = useState(false); const syncUserFromServer = useCallback( @@ -277,6 +280,9 @@ export default function ProfileScreen() { const openDeleteAccountModal = () => { setDeletePassword(''); + setDeleteCode(''); + setDeleteStage('request'); + setDeleteAttemptsRemaining(3); setDeleteModalVisible(true); }; @@ -284,9 +290,11 @@ export default function ProfileScreen() { if (isDeletingAccount) return; setDeleteModalVisible(false); setDeletePassword(''); + setDeleteCode(''); + setDeleteStage('request'); }; - const submitDeleteAccount = async () => { + const requestDeleteCode = async () => { if (!user) return; if (user.authProvider === 'local' && deletePassword.trim().length === 0) { @@ -296,10 +304,38 @@ export default function ProfileScreen() { setIsDeletingAccount(true); try { - await authAPI.deleteAccount({ - confirm: true, + const data = extractApiData(await authAPI.requestAccountDeletion({ ...(user.authProvider === 'local' ? { password: deletePassword.trim() } : {}), - }); + })); + setDeleteStage('verify'); + setDeleteAttemptsRemaining(data.attemptsRemaining); + if (data.devCode) setDeleteCode(data.devCode); + Alert.alert(t('common.ok'), data.devCode ? `${data.message}\nKod: ${data.devCode}` : data.message); + } catch (e: any) { + const apiError = e instanceof ApiError ? e : null; + if (apiError?.data?.contactAdmin) { + setDeleteModalVisible(false); + Alert.alert(t('profile.errorTitle'), apiError.message, [ + { text: t('profile.cancel'), style: 'cancel' }, + { text: 'Adminga murojaat', onPress: () => router.push('/feedback' as any) }, + ]); + } else { + Alert.alert(t('profile.errorTitle'), apiError?.message || t('profile.deleteErrorMsg')); + } + } finally { + setIsDeletingAccount(false); + } + }; + + const submitDeleteAccount = async () => { + if (deleteCode.trim().length !== 6) { + Alert.alert(t('profile.errorTitle'), 'Emailga kelgan 6 xonali kodni kiriting.'); + return; + } + + setIsDeletingAccount(true); + try { + await authAPI.deleteAccount({ confirm: true, code: deleteCode.trim() }); await clearAll(); setDeleteModalVisible(false); Alert.alert(t('profile.deleteSuccessTitle'), t('profile.deleteSuccessMsg')); @@ -316,6 +352,11 @@ export default function ProfileScreen() { }; const menu = [ + { + icon: 'calendar-outline' as const, + label: 'Mening bookinglarim', + onPress: () => router.push('/bookings' as any), + }, { icon: 'chatbubble-ellipses-outline' as const, label: t('profile.feedback', { defaultValue: 'Fikr va shikoyatlar' }), @@ -326,11 +367,6 @@ export default function ProfileScreen() { label: 'Tilni tanlash', onPress: () => router.push('/language' as any), }, - { - icon: 'card-outline' as const, - label: 'To‘lov usullari', - onPress: () => router.push('/payment-methods' as any), - }, { icon: 'gift-outline' as const, label: 'Aksiyalar', @@ -765,9 +801,13 @@ export default function ProfileScreen() { {t('profile.deleteModalTitle')} - {t('profile.deleteModalSub')} + + {deleteStage === 'request' + ? 'Avval emailingizga tasdiqlash kodi yuboramiz. Kodni jami 3 marta so‘rashingiz mumkin.' + : `${user?.email} manziliga yuborilgan 6 xonali kodni kiriting. Qolgan so‘rov: ${deleteAttemptsRemaining}.`} + - {user?.authProvider === 'local' ? ( + {deleteStage === 'request' && user?.authProvider === 'local' ? ( <> {t('profile.deletePasswordLabel')} - ) : ( + ) : deleteStage === 'request' ? ( {t('profile.deleteGoogleHint')} + ) : ( + <> + Tasdiqlash kodi + setDeleteCode(value.replace(/\D/g, '').slice(0, 6))} + editable={!isDeletingAccount} + placeholder="000000" + placeholderTextColor={colors.textMuted} + style={styles.modalInput} + keyboardType="number-pad" + maxLength={6} + /> + + + {deleteAttemptsRemaining > 0 ? `Kodni qayta yuborish (${deleteAttemptsRemaining})` : 'Limit tugadi, adminga murojaat qiling'} + + + )} @@ -800,14 +863,16 @@ export default function ProfileScreen() { {isDeletingAccount ? ( ) : ( - {t('profile.deleteNow')} + + {deleteStage === 'request' ? 'Kodni yuborish' : t('profile.deleteNow')} + )} diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 832b4c8..f2cb11c 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -126,6 +126,7 @@ function RootNavigator() { + diff --git a/mobile/app/bookings.tsx b/mobile/app/bookings.tsx new file mode 100644 index 0000000..5be096d --- /dev/null +++ b/mobile/app/bookings.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Image, RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router, useFocusEffect } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { FONTS } from '../src/constants/fonts'; +import { RADIUS, SPACING } from '../src/constants/spacing'; +import { type AppColors, useAppTheme } from '../src/theme/app-theme'; +import { bookingsAPI, resolveMediaUrl, type TourBookingItemPayload } from '../src/utils/api'; +import { extractApiData } from '../src/utils/auth'; +import { getItem, KEYS } from '../src/utils/storage'; + +function remaining(deadline?: string | null) { + if (!deadline) return 'Deadline belgilanmagan'; + const distance = new Date(deadline).getTime() - Date.now(); + if (distance <= 0) return 'Javob muddati tugagan'; + const hours = Math.floor(distance / 3_600_000); + const minutes = Math.floor((distance % 3_600_000) / 60_000); + const seconds = Math.floor((distance % 60_000) / 1000); + return `${hours ? `${hours} soat ` : ''}${minutes} daqiqa ${seconds} soniya`; +} + +export default function BookingsScreen() { + const { colors } = useAppTheme(); + const insets = useSafeAreaInsets(); + const styles = createStyles(colors); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [, setTick] = useState(0); + + const load = useCallback(async (refresh = false) => { + const token = await getItem(KEYS.TOKEN); + if (!token) { + setLoading(false); + return; + } + if (refresh) setRefreshing(true); + try { + const data = extractApiData<{ items: TourBookingItemPayload[] }>(await bookingsAPI.getMine()); + setItems(data.items || []); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useFocusEffect(useCallback(() => { load(); }, [load])); + useEffect(() => { + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, []); + + return ( + load(true)} tintColor={colors.primary} />} + > + + router.back()}> + + + + WEB VA MOBIL BIR XIL + Mening bookinglarim + + + + {loading ? : null} + {!loading && items.length === 0 ? ( + + + Booking hali yo‘q + Web yoki mobil ilovada shu akkaunt bilan yaratilgan bookinglar shu yerda chiqadi. + router.push('/home-tours' as never)}> + Tourlarni ko‘rish + + + ) : null} + + {items.map((booking) => { + const imageUrl = resolveMediaUrl(booking.tour?.imageUrl); + return ( + + {imageUrl ? : null} + + + {booking.status} + {booking.totalEstimate ? `${booking.totalEstimate} ${booking.currency}` : 'Narx kelishiladi'} + + {booking.tour?.title || 'Tour'} + {booking.tour?.city} · {booking.tour?.duration} · {booking.travelers} kishi + Agentlik: {booking.agency?.name || 'Belgilanmagan'} + Kontakt: {booking.agency?.phone || booking.agency?.website || 'Kutilmoqda'} + Email: {booking.customerEmail} + {booking.message ? Izoh: {booking.message} : null} + {booking.status === 'pending' ? ( + + + {remaining(booking.responseDeadlineAt)} + + ) : null} + + + ); + })} + + + ); +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.background }, + content: { padding: SPACING.lg, gap: SPACING.md }, + header: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md, marginBottom: SPACING.md }, + back: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center' }, + eyebrow: { fontFamily: FONTS.semibold, color: colors.primary, fontSize: 10, letterSpacing: 1 }, + title: { fontFamily: FONTS.display, color: colors.text, fontSize: 25 }, + card: { overflow: 'hidden', borderRadius: RADIUS.xl, backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.borderLight }, + image: { width: '100%', height: 190 }, + body: { padding: SPACING.lg, gap: 7 }, + statusRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: SPACING.sm }, + status: { fontFamily: FONTS.semibold, fontSize: 11, color: colors.primary, textTransform: 'uppercase' }, + price: { fontFamily: FONTS.semibold, fontSize: 13, color: colors.text }, + cardTitle: { fontFamily: FONTS.display, fontSize: 20, color: colors.text }, + muted: { fontFamily: FONTS.regular, fontSize: 12, lineHeight: 18, color: colors.textMuted }, + detail: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textSecondary }, + countdown: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, alignSelf: 'flex-start', marginTop: SPACING.sm, paddingHorizontal: 12, paddingVertical: 8, borderRadius: RADIUS.full, backgroundColor: colors.warningPale }, + countdownText: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.warning }, + empty: { alignItems: 'center', gap: SPACING.md, padding: SPACING.xl, borderRadius: RADIUS.xl, backgroundColor: colors.surface }, + emptyTitle: { fontFamily: FONTS.display, fontSize: 21, color: colors.text }, + primary: { paddingHorizontal: SPACING.xl, paddingVertical: SPACING.md, borderRadius: RADIUS.full, backgroundColor: colors.primary }, + primaryText: { fontFamily: FONTS.semibold, color: colors.textInverse }, + }); +} diff --git a/mobile/app/profile-edit.tsx b/mobile/app/profile-edit.tsx index 7e736a2..20424a6 100644 --- a/mobile/app/profile-edit.tsx +++ b/mobile/app/profile-edit.tsx @@ -21,9 +21,9 @@ import Button from '../src/components/Button'; import { FONTS } from '../src/constants/fonts'; import { RADIUS, SPACING } from '../src/constants/spacing'; import { type AppColors, useAppTheme } from '../src/theme/app-theme'; -import { ApiError, authAPI } from '../src/utils/api'; +import { ApiError, authAPI, type SecurityCodePayload } from '../src/utils/api'; import { type AuthUser, extractApiData, getUserInitials } from '../src/utils/auth'; -import { KEYS, getJSON, saveUserProfile } from '../src/utils/storage'; +import { KEYS, getJSON, saveAuthSession, saveUserProfile } from '../src/utils/storage'; const MAX_AVATAR_DATA_URI_LENGTH = 750_000; @@ -47,6 +47,12 @@ export default function ProfileEditScreen() { const [bio, setBio] = useState(''); const [avatarUrl, setAvatarUrl] = useState(null); const [loading, setLoading] = useState(false); + const [newEmail, setNewEmail] = useState(''); + const [emailPassword, setEmailPassword] = useState(''); + const [emailCode, setEmailCode] = useState(''); + const [emailStage, setEmailStage] = useState<'request' | 'verify'>('request'); + const [emailAttemptsRemaining, setEmailAttemptsRemaining] = useState(3); + const [emailLoading, setEmailLoading] = useState(false); useEffect(() => { getJSON(KEYS.USER).then((value) => { @@ -164,6 +170,71 @@ export default function ProfileEditScreen() { fullName: [name, lastName].filter(Boolean).join(' ').trim(), }); + const requestEmailCode = async () => { + const normalizedEmail = newEmail.trim().toLowerCase(); + if (!normalizedEmail || !normalizedEmail.includes('@')) { + Alert.alert(t('auth.errorTitle'), 'Yangi email manzilini to‘g‘ri kiriting.'); + return; + } + if (normalizedEmail === user?.email.toLowerCase()) { + Alert.alert(t('auth.errorTitle'), 'Yangi email joriy emaildan farq qilishi kerak.'); + return; + } + if (user?.authProvider === 'local' && !emailPassword.trim()) { + Alert.alert(t('auth.errorTitle'), 'Joriy parolni kiriting.'); + return; + } + + setEmailLoading(true); + try { + const data = extractApiData(await authAPI.requestEmailChange({ + newEmail: normalizedEmail, + ...(user?.authProvider === 'local' ? { password: emailPassword.trim() } : {}), + })); + setEmailStage('verify'); + setEmailAttemptsRemaining(data.attemptsRemaining); + if (data.devCode) setEmailCode(data.devCode); + Alert.alert(t('common.ok'), data.devCode ? `${data.message}\nKod: ${data.devCode}` : data.message); + } catch (error) { + const apiError = error instanceof ApiError ? error : null; + if (apiError?.data?.contactAdmin) { + Alert.alert(t('auth.errorTitle'), apiError.message, [ + { text: t('profile.cancel'), style: 'cancel' }, + { text: 'Adminga murojaat', onPress: () => router.push('/feedback' as any) }, + ]); + } else { + Alert.alert(t('auth.errorTitle'), apiError?.message || 'Kod yuborilmadi.'); + } + } finally { + setEmailLoading(false); + } + }; + + const verifyEmailCode = async () => { + if (emailCode.trim().length !== 6) { + Alert.alert(t('auth.errorTitle'), '6 xonali kodni kiriting.'); + return; + } + setEmailLoading(true); + try { + const data = extractApiData<{ message?: string; token: string; user: AuthUser }>( + await authAPI.verifyEmailChange({ code: emailCode.trim() }) + ); + await saveAuthSession(data.token, data.user); + setUser(data.user); + setNewEmail(''); + setEmailPassword(''); + setEmailCode(''); + setEmailStage('request'); + setEmailAttemptsRemaining(3); + Alert.alert(t('common.ok'), data.message || 'Email almashtirildi.'); + } catch (error) { + Alert.alert(t('auth.errorTitle'), error instanceof ApiError ? error.message : 'Email tasdiqlanmadi.'); + } finally { + setEmailLoading(false); + } + }; + return ( + + + Emailni almashtirish + + Tasdiqlash kodi eski emailingizga yuboriladi: {user?.email || '—'} + + Yangi email + + {emailStage === 'request' && user?.authProvider === 'local' ? ( + <> + Joriy parol + + + ) : null} + {emailStage === 'verify' ? ( + <> + Eski emailga kelgan kod + setEmailCode(value.replace(/\D/g, '').slice(0, 6))} + keyboardType="number-pad" + maxLength={6} + placeholder="000000" + placeholderTextColor={colors.textMuted} + /> + + Kodni qayta yuborish ({emailAttemptsRemaining}) + + + ) : null} + : null} + +
+ {!token || !user ? ( +
+ +

{mode === "register" ? "Yangi foydalanuvchi" : mode === "verify" ? "Gmail tasdiqlash" : "Foydalanuvchi hisobi"}

+

{mode === "register" ? "Ro‘yxatdan o‘tish" : mode === "verify" ? "Kodni kiriting" : "Kirish"}

+ Bitta Gmail bilan faqat bir marta ro‘yxatdan o‘tish mumkin. + {error ?
{error}
: null} + {message ?
{message}
: null} + {mode === "register" ? : null} + + {mode !== "verify" ? : null} + {mode === "verify" ? : null} + + {mode !== "verify" ? ( + <> +
yoki
+ + + ) : null} + {mode === "verify" ? : null} + + + ) : ( + <> +
+
+

Yagona web va mobil akkaunt

+

{user.fullName || user.name}, barcha sayohatlaringiz

+ {user.email} +
+ +
+ {error ?
{error}
: null} + {message ?
{message}
: null} + +
+
+
+ {user.avatarUrl ? ( + {user.fullName + ) : ( + + )} +
+
+

Foydalanuvchi profili

+

{user.fullName || user.name}

+ {user.email} + {user.bio ?

{user.bio}

: null} +
+ {user.emailVerified ? "Email tasdiqlangan" : "Email tasdiqlanmagan"} + {user.authProvider === "google" ? "Google akkaunt" : "Email akkaunt"} +
+
+
+ +
+

Ilovadagi sayohat sozlamalari

+

{preferences ? styleLabel(preferences.style) : "Hali tanlanmagan"}

+
+ {preferences?.interests.length + ? preferences.interests.map((interest) => {interest}) + : Qiziqishlar mobil ilovada tanlangandan keyin shu yerda ko‘rinadi.} +
+
+
+ +
+
Safarlar{trips.length}
+
Saqlangan joylar{wishlist.length}
+
Bookinglar{bookings.length}
+
Yutuqlar{achievements?.unlockedCount || 0}/{achievements?.totalCount || 0}
+
Shaharlar{achievements?.stats.uniqueCities || 0}
+
Jami xarajat{formatMoney(achievements?.stats.totalSpent)}
+
+ +
+
+

Akkaunt xavfsizligi

+

Emailni almashtirish

+ Tasdiqlash kodi eski emailingizga yuboriladi: {user.email} + + {emailChangeStage === "request" && user.authProvider === "local" ? ( + + ) : null} + {emailChangeStage === "verify" ? ( + + ) : null} + + {emailChangeStage === "verify" ? ( + + ) : null} +
+ +
+

Muhim amal

+

Akkauntni o‘chirish

+ Barcha safarlar, wishlist, sharhlar va booking bog‘lanishlari o‘chiriladi. + {deleteStage === "request" && user.authProvider === "local" ? ( + + ) : null} + {deleteStage === "verify" ? ( + + ) : null} + + {deleteStage === "verify" ? ( + + ) : null} +
+
+ + {selectedTour ? ( +
+
+ {selectedTour.imageUrl ? {selectedTour.title} : null} +
+

{selectedTour.agency?.name || "TravelorAI partner"}

+

{selectedTour.title}

+ {selectedTour.city} · {selectedTour.duration} · {selectedTour.responseTimeMinutes || 45} daqiqada javob +
+
+
+ + + +