diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..bb6ee49
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+Dockerfile
+.dockerignore
+node_modules
+npm-debug.log
+README.md
+.env
+.env.local
+.env.production.local
+.env.development.local
+.git
+.gitignore
+*.md
+.next
+.vercel
+.DS_Store
+*.tsbuildinfo
+stack.env
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..82a1c0c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,62 @@
+# Используем официальный Node.js образ
+FROM node:18-alpine AS base
+
+# Устанавливаем зависимости только когда нужно
+FROM base AS deps
+# Проверяем https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine чтобы понять зачем нужен libc6-compat
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+# Устанавливаем зависимости на основе предпочтительного менеджера пакетов
+COPY package.json package-lock.json* ./
+RUN \
+ if [ -f package-lock.json ]; then npm ci; \
+ else echo "Lockfile not found." && exit 1; \
+ fi
+
+# Собираем исходный код только когда нужно
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# Генерируем Prisma клиент
+RUN npx prisma generate
+
+# Собираем приложение
+RUN npm run build
+
+# Удаляем dev зависимости для production
+RUN npm prune --production
+
+# Продакшн образ, копируем все файлы и запускаем next
+FROM base AS runner
+WORKDIR /app
+
+ENV NODE_ENV production
+# Отключаем телеметрию next.js во время runtime
+ENV NEXT_TELEMETRY_DISABLED 1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+
+# Автоматически использовать output traces для уменьшения размера образа
+# https://nextjs.org/docs/advanced-features/output-file-tracing
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+# Копируем Prisma схему и клиент для runtime
+COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
+COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
+COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT 3000
+ENV HOSTNAME "0.0.0.0"
+
+CMD ["node", "server.js"]
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ae689f8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,20 @@
+version: '3.8'
+
+services:
+ app:
+ build: .
+ ports:
+ - "3017:3000"
+ env_file:
+ - stack.env
+ environment:
+ - NODE_ENV=production
+ - PORT=3000
+ - HOSTNAME=0.0.0.0
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
+ timeout: 10s
+ interval: 30s
+ retries: 3
+ start_period: 40s
\ No newline at end of file
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..225e495 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: 'standalone',
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index f6b771d..d9435dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@as-integrations/next": "^3.2.0",
"@aws-sdk/client-s3": "^3.846.0",
"@prisma/client": "^6.12.0",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
@@ -2752,6 +2753,34 @@
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
+ "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.14",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
diff --git a/package.json b/package.json
index 46096b1..726cc72 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"@as-integrations/next": "^3.2.0",
"@aws-sdk/client-s3": "^3.846.0",
"@prisma/client": "^6.12.0",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0226bdb..411e6d8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -114,6 +114,10 @@ model Organization {
sentMessages Message[] @relation("SentMessages")
receivedMessages Message[] @relation("ReceivedMessages")
+ // Услуги и расходники (только для фулфилмент центров)
+ services Service[]
+ supplies Supply[]
+
@@map("organizations")
}
@@ -235,3 +239,38 @@ enum MessageType {
IMAGE
FILE
}
+
+// Модель услуг (для фулфилмент центров)
+model Service {
+ id String @id @default(cuid())
+ name String
+ description String?
+ price Decimal @db.Decimal(10,2) // Цена за единицу
+ imageUrl String? // URL фотографии в S3
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Связь с организацией
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ organizationId String
+
+ @@map("services")
+}
+
+// Модель расходников (для фулфилмент центров)
+model Supply {
+ id String @id @default(cuid())
+ name String
+ description String?
+ price Decimal @db.Decimal(10,2) // Цена за единицу
+ quantity Int @default(0) // Количество в наличии
+ imageUrl String? // URL фотографии в S3
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Связь с организацией
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ organizationId String
+
+ @@map("supplies")
+}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
new file mode 100644
index 0000000..af857df
--- /dev/null
+++ b/src/app/api/health/route.ts
@@ -0,0 +1,5 @@
+import { NextResponse } from 'next/server'
+
+export async function GET() {
+ return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
+}
\ No newline at end of file
diff --git a/src/app/api/upload-service-image/route.ts b/src/app/api/upload-service-image/route.ts
new file mode 100644
index 0000000..1b2242c
--- /dev/null
+++ b/src/app/api/upload-service-image/route.ts
@@ -0,0 +1,163 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
+
+const s3Client = new S3Client({
+ region: 'ru-1',
+ endpoint: 'https://s3.twcstorage.ru',
+ credentials: {
+ accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
+ secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ'
+ },
+ forcePathStyle: true
+})
+
+const BUCKET_NAME = '617774af-sfera'
+
+// Разрешенные типы изображений
+const ALLOWED_IMAGE_TYPES = [
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif'
+]
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData()
+ const file = formData.get('file') as File
+ const userId = formData.get('userId') as string
+ const type = formData.get('type') as string // 'service' или 'supply'
+
+ if (!file || !userId || !type) {
+ return NextResponse.json(
+ { error: 'File, userId and type are required' },
+ { status: 400 }
+ )
+ }
+
+ // Проверяем тип (services или supplies)
+ if (!['service', 'supply'].includes(type)) {
+ return NextResponse.json(
+ { error: 'Type must be either "service" or "supply"' },
+ { status: 400 }
+ )
+ }
+
+ // Проверяем, что файл не пустой
+ if (file.size === 0) {
+ return NextResponse.json(
+ { error: 'File is empty' },
+ { status: 400 }
+ )
+ }
+
+ // Проверяем имя файла
+ if (!file.name || file.name.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'Invalid file name' },
+ { status: 400 }
+ )
+ }
+
+ // Проверяем тип файла
+ if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
+ return NextResponse.json(
+ { error: `File type ${file.type} is not allowed. Only images are supported.` },
+ { status: 400 }
+ )
+ }
+
+ // Ограничиваем размер файла (5MB для изображений)
+ if (file.size > 5 * 1024 * 1024) {
+ return NextResponse.json(
+ { error: 'File size must be less than 5MB' },
+ { status: 400 }
+ )
+ }
+
+ // Генерируем уникальное имя файла
+ const timestamp = Date.now()
+ // Более безопасная очистка имени файла
+ const safeFileName = file.name
+ .replace(/[^\w\s.-]/g, '_') // Заменяем недопустимые символы
+ .replace(/\s+/g, '_') // Заменяем пробелы на подчеркивания
+ .replace(/_{2,}/g, '_') // Убираем множественные подчеркивания
+ .toLowerCase() // Приводим к нижнему регистру
+
+ const folder = type === 'service' ? 'services' : 'supplies'
+ const key = `${folder}/${userId}/${timestamp}-${safeFileName}`
+
+ // Конвертируем файл в Buffer
+ const buffer = Buffer.from(await file.arrayBuffer())
+
+ // Очищаем метаданные от недопустимых символов
+ const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_')
+ const cleanUserId = userId.replace(/[^\w-]/g, '')
+ const cleanType = type.replace(/[^\w]/g, '')
+
+ // Загружаем в S3
+ const command = new PutObjectCommand({
+ Bucket: BUCKET_NAME,
+ Key: key,
+ Body: buffer,
+ ContentType: file.type,
+ ACL: 'public-read',
+ Metadata: {
+ originalname: cleanOriginalName,
+ uploadedby: cleanUserId,
+ type: cleanType
+ }
+ })
+
+ await s3Client.send(command)
+
+ // Возвращаем URL файла и метаданные
+ const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}`
+
+ return NextResponse.json({
+ success: true,
+ url,
+ key,
+ originalName: file.name,
+ size: file.size,
+ type: file.type
+ })
+
+ } catch (error) {
+ console.error('Error uploading service image:', error)
+ return NextResponse.json(
+ { error: 'Failed to upload image' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ try {
+ const { key } = await request.json()
+
+ if (!key) {
+ return NextResponse.json(
+ { error: 'Key is required' },
+ { status: 400 }
+ )
+ }
+
+ // TODO: Добавить удаление из S3
+ // const command = new DeleteObjectCommand({
+ // Bucket: BUCKET_NAME,
+ // Key: key
+ // })
+ // await s3Client.send(command)
+
+ return NextResponse.json({ success: true })
+
+ } catch (error) {
+ console.error('Error deleting service image:', error)
+ return NextResponse.json(
+ { error: 'Failed to delete image' },
+ { status: 500 }
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 87f2c5f..cbe1825 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@
import { ApolloProvider } from '@apollo/client'
import { apolloClient } from '@/lib/apollo-client'
+import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
export default function RootLayout({
@@ -15,6 +16,7 @@ export default function RootLayout({
{children}
+