diff --git a/.gitignore b/.gitignore
index 06dd0a5..1c0b173 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,8 @@ pnpm-debug.log*
.env
.env.*
!.env.example
+!deploy/.env.prod.example
+
+# Production build output (Apache DocumentRoot)
+deploy/dist/
+deploy/.env.prod
diff --git a/README.md b/README.md
index 1242346..c813f6e 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,18 @@ social-graph/
└── docker-compose.yml
```
+## Production (Docker + прокси на хосте)
+
+См. [deploy/README.md](deploy/README.md).
+
+Кратко:
+
+```bash
+cp deploy/.env.prod.example deploy/.env.prod
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend
+# прокси на хосте → http://127.0.0.1:8080 (см. deploy/apache/social-graph.conf)
+```
+
## Запуск без Docker
**Backend:**
diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod
new file mode 100644
index 0000000..e53e88b
--- /dev/null
+++ b/backend/Dockerfile.prod
@@ -0,0 +1,18 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+ENV DJANGO_SETTINGS_MODULE=config.settings
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt gunicorn
+
+COPY . .
+
+RUN mkdir -p /app/data
+
+EXPOSE 8000
+
+CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 2 --timeout 120"]
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 5199339..5e2d4dc 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -1,10 +1,16 @@
+import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
-SECRET_KEY = 'django-insecure-social-graph-dev-key-change-in-production'
-DEBUG = True
-ALLOWED_HOSTS = ['*']
+SECRET_KEY = os.environ.get(
+ 'SECRET_KEY',
+ 'django-insecure-social-graph-dev-key-change-in-production',
+)
+DEBUG = os.environ.get('DEBUG', 'True').lower() in ('1', 'true', 'yes')
+ALLOWED_HOSTS = [
+ h.strip() for h in os.environ.get('ALLOWED_HOSTS', '*').split(',') if h.strip()
+]
INSTALLED_APPS = [
'django.contrib.contenttypes',
@@ -25,7 +31,7 @@ ROOT_URLCONF = 'config.urls'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': BASE_DIR / 'db.sqlite3',
+ 'NAME': os.environ.get('DATABASE_PATH', str(BASE_DIR / 'db.sqlite3')),
}
}
diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py
new file mode 100644
index 0000000..a9f185c
--- /dev/null
+++ b/backend/config/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
+
+application = get_wsgi_application()
diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example
new file mode 100644
index 0000000..476a785
--- /dev/null
+++ b/deploy/.env.prod.example
@@ -0,0 +1,12 @@
+# Режим данных при сборке фронта: local | remote | hybrid
+VITE_DATA_MODE=local
+
+# Порты на хосте (для прокси-сервера)
+FRONTEND_BIND=127.0.0.1
+FRONTEND_PORT=8080
+BACKEND_BIND=127.0.0.1
+BACKEND_PORT=8000
+
+# Только при profile with-backend (VITE_DATA_MODE=remote)
+DJANGO_SECRET_KEY=replace-with-long-random-string
+ALLOWED_HOSTS=your-domain.com,www.your-domain.com
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..af5ab9b
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,91 @@
+# Production deploy (контейнеры + прокси на хосте)
+
+Схема:
+
+```text
+[Прокси на хосте :80/:443]
+ → frontend-контейнер (nginx :8080 на хосте)
+ → backend-контейнер (gunicorn :8000, только при remote)
+```
+
+SPA-маршрутизация (`try_files` → `index.html`) выполняется **внутри** frontend-контейнера.
+
+## 1. Подготовка
+
+```bash
+cd /opt/social-graph
+cp deploy/.env.prod.example deploy/.env.prod
+# отредактируйте deploy/.env.prod при необходимости
+```
+
+## 2. Local-first (только frontend)
+
+Рекомендуется для конфиденциальности — данные в браузере (IndexedDB).
+
+```bash
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build frontend
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend
+```
+
+Проверка:
+
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
+# ожидается 200
+```
+
+## 3. Прокси на хосте
+
+### Apache2
+
+```bash
+sudo a2enmod proxy proxy_http headers
+sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf
+# ServerName и порты (8080 / 8000) — по вашему .env.prod
+sudo a2ensite social-graph.conf
+sudo apache2ctl configtest
+sudo systemctl reload apache2
+```
+
+### nginx на хосте
+
+См. `deploy/proxy/nginx-host.conf.example`.
+
+## 4. Remote mode (frontend + backend)
+
+В `deploy/.env.prod`:
+
+```env
+VITE_DATA_MODE=remote
+DJANGO_SECRET_KEY=...
+ALLOWED_HOSTS=your-domain.com
+```
+
+Пересоберите frontend (режим зашивается при build) и поднимите оба сервиса:
+
+```bash
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d backend
+```
+
+В `deploy/apache/social-graph.conf` раскомментируйте блок `ProxyPass /api ...` **перед** `ProxyPass /`.
+
+## 5. Обновление
+
+```bash
+git pull
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build frontend
+docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend
+# при remote — также build/up backend
+sudo systemctl reload apache2 # или nginx -s reload
+```
+
+## Порты по умолчанию
+
+| Сервис | Хост (loopback) | Внутри контейнера |
+|----------|-----------------|-------------------|
+| frontend | 127.0.0.1:8080 | nginx :80 |
+| backend | 127.0.0.1:8000 | gunicorn :8000 |
+
+Наружу открыт только прокси на хосте (80/443).
diff --git a/deploy/apache/social-graph.conf b/deploy/apache/social-graph.conf
new file mode 100644
index 0000000..5a974d0
--- /dev/null
+++ b/deploy/apache/social-graph.conf
@@ -0,0 +1,31 @@
+# Прокси на хосте → контейнеры Docker.
+# Скопировать:
+# sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf
+#
+# Перед включением поднимите контейнеры:
+# docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d frontend
+# # при remote: ... --profile with-backend up -d
+#
+# sudo a2enmod proxy proxy_http headers rewrite
+# sudo a2ensite social-graph.conf
+# sudo apache2ctl configtest && sudo systemctl reload apache2
+
+
+ ServerName your-domain.com
+ ServerAlias www.your-domain.com
+
+ ProxyPreserveHost On
+ RequestHeader set X-Forwarded-Proto "http"
+ RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
+
+ # Backend API (раскомментируйте при VITE_DATA_MODE=remote и profile with-backend)
+ # ProxyPass /api http://127.0.0.1:8000/api
+ # ProxyPassReverse /api http://127.0.0.1:8000/api
+
+ # Frontend SPA (nginx в контейнере sg_frontend)
+ ProxyPass / http://127.0.0.1:8080/
+ ProxyPassReverse / http://127.0.0.1:8080/
+
+ ErrorLog ${APACHE_LOG_DIR}/social-graph-error.log
+ CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined
+
diff --git a/deploy/proxy/nginx-host.conf.example b/deploy/proxy/nginx-host.conf.example
new file mode 100644
index 0000000..ce5c3aa
--- /dev/null
+++ b/deploy/proxy/nginx-host.conf.example
@@ -0,0 +1,24 @@
+# Пример для nginx на хосте (не в контейнере).
+# Порты должны совпадать с deploy/.env.prod (FRONTEND_PORT, BACKEND_PORT).
+
+server {
+ listen 80;
+ server_name your-domain.com;
+
+ # Раскомментируйте при VITE_DATA_MODE=remote
+ # location /api/ {
+ # proxy_pass http://127.0.0.1:8000/api/;
+ # proxy_set_header Host $host;
+ # proxy_set_header X-Real-IP $remote_addr;
+ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # proxy_set_header X-Forwarded-Proto $scheme;
+ # }
+
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..942d4dd
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,32 @@
+services:
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile.prod
+ args:
+ VITE_DATA_MODE: ${VITE_DATA_MODE:-local}
+ container_name: sg_frontend
+ ports:
+ - "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-8080}:80"
+ restart: unless-stopped
+
+ backend:
+ profiles: ["with-backend"]
+ build:
+ context: ./backend
+ dockerfile: Dockerfile.prod
+ container_name: sg_backend
+ environment:
+ DJANGO_SETTINGS_MODULE: config.settings
+ SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me-in-production}
+ DEBUG: "False"
+ ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
+ DATABASE_PATH: /app/data/db.sqlite3
+ volumes:
+ - sqlite_data:/app/data
+ ports:
+ - "${BACKEND_BIND:-127.0.0.1}:${BACKEND_PORT:-8000}:8000"
+ restart: unless-stopped
+
+volumes:
+ sqlite_data:
diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod
new file mode 100644
index 0000000..b346baa
--- /dev/null
+++ b/frontend/Dockerfile.prod
@@ -0,0 +1,24 @@
+# syntax=docker/dockerfile:1
+
+FROM node:20-alpine AS build
+
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY . .
+
+ARG VITE_DATA_MODE=local
+ENV VITE_DATA_MODE=${VITE_DATA_MODE}
+
+RUN npm run build
+
+FROM nginx:1.27-alpine AS runtime
+
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..00badee
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,20 @@
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ gzip on;
+ gzip_types text/css application/javascript application/json image/svg+xml;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+ expires 7d;
+ add_header Cache-Control "public, immutable";
+ try_files $uri =404;
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5c70bca..6b78f97 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"axios": "^1.6.7",
+ "dexie": "^4.2.1",
"pinia": "^2.1.7",
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
@@ -812,6 +813,12 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dexie": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.3.tgz",
+ "integrity": "sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==",
+ "license": "Apache-2.0"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
diff --git a/frontend/package.json b/frontend/package.json
index 80fb310..69502b6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"axios": "^1.6.7",
+ "dexie": "^4.2.1",
"vis-network": "^9.1.9",
"vis-data": "^7.1.9"
},
diff --git a/frontend/src/application/usecases/contacts.js b/frontend/src/application/usecases/contacts.js
new file mode 100644
index 0000000..f0d7e84
--- /dev/null
+++ b/frontend/src/application/usecases/contacts.js
@@ -0,0 +1,44 @@
+import { appendChange } from '../../infrastructure/sync/changeLogRepository'
+import { getContactRepository } from '../../infrastructure/repositories/repositoryFactory'
+
+const contactRepo = () => getContactRepository()
+
+export async function listContacts(search = '') {
+ return contactRepo().list(search)
+}
+
+export async function getContactById(id) {
+ return contactRepo().getById(id)
+}
+
+export async function createContact(payload) {
+ const created = await contactRepo().create(payload)
+ await appendChange({
+ entityType: 'contact',
+ entityId: created.id,
+ op: 'created',
+ payloadPatch: created,
+ })
+ return created
+}
+
+export async function updateContact(id, payload) {
+ const updated = await contactRepo().update(id, payload)
+ await appendChange({
+ entityType: 'contact',
+ entityId: id,
+ op: 'updated',
+ payloadPatch: payload,
+ })
+ return updated
+}
+
+export async function deleteContact(id) {
+ await contactRepo().remove(id)
+ await appendChange({
+ entityType: 'contact',
+ entityId: id,
+ op: 'deleted',
+ payloadPatch: {},
+ })
+}
diff --git a/frontend/src/application/usecases/graph.js b/frontend/src/application/usecases/graph.js
new file mode 100644
index 0000000..cab55b9
--- /dev/null
+++ b/frontend/src/application/usecases/graph.js
@@ -0,0 +1,53 @@
+import { listContacts } from './contacts'
+import { listRelations } from './relations'
+import { getRelationTypes, getNetworkMapChoices } from '../../infrastructure/repositories/repositoryFactory'
+
+function nodeFromContact(c) {
+ return {
+ id: c.id,
+ label: c.name,
+ title: [c.organization, c.position, c.email].filter(Boolean).join('\n'),
+ group: c.organization || 'default',
+ life_sphere: c.life_sphere,
+ network_circle: c.network_circle,
+ importance: c.importance,
+ map_angle: c.map_angle,
+ map_radius_ratio: c.map_radius_ratio,
+ }
+}
+
+function edgeFromRelation(r) {
+ return {
+ id: r.id,
+ from: r.source,
+ to: r.target,
+ label: r.relation_type,
+ title: r.description || r.relation_type,
+ relation_type: r.relation_type,
+ interaction_intensity: r.interaction_intensity,
+ }
+}
+
+export async function getGraphBundle({ networkMapOnly = false } = {}) {
+ const [contacts, relations, relationTypes] = await Promise.all([
+ listContacts(),
+ listRelations(),
+ getRelationTypes(),
+ ])
+
+ const scopedContacts = networkMapOnly
+ ? contacts.filter((c) => c.include_on_network_map)
+ : contacts
+ const allowedIds = new Set(scopedContacts.map((c) => c.id))
+ const scopedRelations = relations.filter((r) => allowedIds.has(r.source) && allowedIds.has(r.target))
+
+ return {
+ nodes: scopedContacts.map(nodeFromContact),
+ edges: scopedRelations.map(edgeFromRelation),
+ relationTypes,
+ }
+}
+
+export async function getMapChoices() {
+ return getNetworkMapChoices()
+}
diff --git a/frontend/src/application/usecases/importExport.js b/frontend/src/application/usecases/importExport.js
new file mode 100644
index 0000000..78d5714
--- /dev/null
+++ b/frontend/src/application/usecases/importExport.js
@@ -0,0 +1,189 @@
+import { listContacts, createContact } from './contacts'
+import { listRelations, createRelation } from './relations'
+import { localDb } from '../../infrastructure/db/localDb'
+
+function isLikelyEmail(value) {
+ return value.includes('@') && value.includes('.')
+}
+
+function normalizeRows(raw) {
+ if (Array.isArray(raw)) return raw
+ if (raw && Array.isArray(raw.contacts)) return raw.contacts
+ if (raw && Array.isArray(raw.results)) return raw.results
+ if (raw && Array.isArray(raw.data)) return raw.data
+ return []
+}
+
+function parseCsv(text) {
+ const lines = text.split(/\r?\n/).filter(Boolean)
+ if (!lines.length) return []
+ const headers = lines[0].split(',').map((h) => h.trim())
+ return lines.slice(1).map((line) => {
+ const values = line.split(',')
+ return headers.reduce((acc, header, idx) => {
+ acc[header] = (values[idx] || '').trim()
+ return acc
+ }, {})
+ })
+}
+
+async function readText(file) {
+ return file.text()
+}
+
+function toContactPayload(row) {
+ const name = String(row.name || row.Name || row['ФИО'] || '').trim()
+ const email = String(row.email || '').trim()
+ const phone = String(row.phone || '').trim()
+ const organization = String(row.organization || row.company || '').trim()
+ const position = String(row.position || row.job || '').trim()
+ const notes = String(row.notes || row.description || '').trim()
+ return {
+ name,
+ email: email || (isLikelyEmail(phone) ? phone : ''),
+ phone: isLikelyEmail(phone) ? '' : phone,
+ organization,
+ position,
+ notes,
+ }
+}
+
+export async function importContactsFromFile(file) {
+ if (!file) throw new Error('Файл не выбран')
+ const name = file.name.toLowerCase()
+ const rawText = await readText(file)
+
+ let rows = []
+ if (name.endsWith('.csv')) {
+ rows = parseCsv(rawText)
+ } else if (name.endsWith('.json')) {
+ rows = normalizeRows(JSON.parse(rawText))
+ } else {
+ throw new Error('Поддерживаются только CSV и JSON файлы.')
+ }
+
+ let created = 0
+ let skipped = 0
+ const errors = []
+ for (let i = 0; i < rows.length; i += 1) {
+ const payload = toContactPayload(rows[i])
+ if (!payload.name) {
+ skipped += 1
+ errors.push(`Строка ${i + 1}: отсутствует поле "name"`)
+ continue
+ }
+ await createContact(payload)
+ created += 1
+ }
+
+ return { total: rows.length, created, skipped, errors }
+}
+
+function uint8ToBase64(bytes) {
+ let binary = ''
+ bytes.forEach((b) => {
+ binary += String.fromCharCode(b)
+ })
+ return btoa(binary)
+}
+
+function base64ToUint8(value) {
+ const binary = atob(value)
+ const arr = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ arr[i] = binary.charCodeAt(i)
+ }
+ return arr
+}
+
+async function deriveKey(passphrase, saltBytes) {
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ new TextEncoder().encode(passphrase),
+ 'PBKDF2',
+ false,
+ ['deriveKey']
+ )
+ return crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ hash: 'SHA-256',
+ salt: saltBytes,
+ iterations: 210000,
+ },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt']
+ )
+}
+
+export async function exportLocalData({ passphrase = '' } = {}) {
+ const payload = {
+ version: 1,
+ exportedAt: new Date().toISOString(),
+ contacts: await listContacts(),
+ relations: await listRelations(),
+ changes: await localDb.changelog.toArray(),
+ }
+
+ if (!passphrase) {
+ return {
+ filename: `social-graph-export-${Date.now()}.json`,
+ blob: new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }),
+ }
+ }
+
+ const iv = crypto.getRandomValues(new Uint8Array(12))
+ const salt = crypto.getRandomValues(new Uint8Array(16))
+ const key = await deriveKey(passphrase, salt)
+ const encoded = new TextEncoder().encode(JSON.stringify(payload))
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
+ const wrapped = {
+ version: 1,
+ algorithm: 'AES-GCM',
+ kdf: 'PBKDF2-SHA256',
+ iterations: 210000,
+ salt: uint8ToBase64(salt),
+ iv: uint8ToBase64(iv),
+ data: uint8ToBase64(new Uint8Array(encrypted)),
+ }
+ return {
+ filename: `social-graph-export-${Date.now()}.sgpkg`,
+ blob: new Blob([JSON.stringify(wrapped, null, 2)], { type: 'application/json' }),
+ }
+}
+
+async function decryptPayload(raw, passphrase) {
+ const salt = base64ToUint8(raw.salt)
+ const iv = base64ToUint8(raw.iv)
+ const encrypted = base64ToUint8(raw.data)
+ const key = await deriveKey(passphrase, salt)
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted)
+ return JSON.parse(new TextDecoder().decode(decrypted))
+}
+
+export async function importLocalDump(file, passphrase = '') {
+ const raw = JSON.parse(await file.text())
+ const dump = raw?.data ? await decryptPayload(raw, passphrase) : raw
+
+ if (!Array.isArray(dump.contacts) || !Array.isArray(dump.relations)) {
+ throw new Error('Некорректный формат экспортного файла')
+ }
+
+ await localDb.transaction('rw', localDb.contacts, localDb.relations, localDb.changelog, async () => {
+ for (const contact of dump.contacts) {
+ await localDb.contacts.put(contact)
+ }
+ for (const relation of dump.relations) {
+ await localDb.relations.put(relation)
+ }
+ if (Array.isArray(dump.changes)) {
+ for (const change of dump.changes) {
+ await localDb.changelog.put(change)
+ }
+ }
+ })
+
+ return { importedContacts: dump.contacts.length, importedRelations: dump.relations.length }
+}
diff --git a/frontend/src/application/usecases/relations.js b/frontend/src/application/usecases/relations.js
new file mode 100644
index 0000000..f9017b6
--- /dev/null
+++ b/frontend/src/application/usecases/relations.js
@@ -0,0 +1,29 @@
+import { appendChange } from '../../infrastructure/sync/changeLogRepository'
+import { getRelationRepository } from '../../infrastructure/repositories/repositoryFactory'
+
+const relationRepo = () => getRelationRepository()
+
+export async function listRelations() {
+ return relationRepo().list()
+}
+
+export async function createRelation(payload) {
+ const relation = await relationRepo().create(payload)
+ await appendChange({
+ entityType: 'relation',
+ entityId: relation.id,
+ op: 'created',
+ payloadPatch: relation,
+ })
+ return relation
+}
+
+export async function deleteRelation(id) {
+ await relationRepo().remove(id)
+ await appendChange({
+ entityType: 'relation',
+ entityId: id,
+ op: 'deleted',
+ payloadPatch: {},
+ })
+}
diff --git a/frontend/src/application/usecases/sync.js b/frontend/src/application/usecases/sync.js
new file mode 100644
index 0000000..73df35f
--- /dev/null
+++ b/frontend/src/application/usecases/sync.js
@@ -0,0 +1,15 @@
+import { ackChanges, listPendingChanges } from '../../infrastructure/sync/changeLogRepository'
+import { getSyncAdapter } from '../../infrastructure/sync/syncAdapter'
+import { isLocalMode } from '../../infrastructure/config/dataMode'
+
+export async function syncPendingChanges() {
+ if (isLocalMode()) return { pushed: 0, acknowledged: 0 }
+ const adapter = getSyncAdapter()
+ const pending = await listPendingChanges()
+ if (!pending.length) return { pushed: 0, acknowledged: 0 }
+
+ const response = await adapter.pushChanges(pending)
+ const ackIds = response?.acknowledgedIds || []
+ await ackChanges(ackIds)
+ return { pushed: pending.length, acknowledged: ackIds.length }
+}
diff --git a/frontend/src/components/NetworkMapTopPanel.vue b/frontend/src/components/NetworkMapTopPanel.vue
index 0edadb2..c13a14d 100644
--- a/frontend/src/components/NetworkMapTopPanel.vue
+++ b/frontend/src/components/NetworkMapTopPanel.vue
@@ -14,9 +14,10 @@
+
+
+ Бэкап локальной базы
+
+ Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен.
+
+
+
+
+
+
+
+
+
+
@@ -81,6 +106,8 @@ const selectedFile = ref(null)
const isDragging = ref(false)
const importing = ref(false)
const result = ref(null)
+const backupPassphrase = ref('')
+const busyBackup = ref(false)
function onFileSelect(e) {
selectedFile.value = e.target.files[0] || null
@@ -111,6 +138,42 @@ function reset() {
result.value = null
if (fileInput.value) fileInput.value.value = ''
}
+
+async function doExport() {
+ busyBackup.value = true
+ try {
+ const { blob, filename } = await store.exportData(backupPassphrase.value)
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ link.click()
+ URL.revokeObjectURL(url)
+ } finally {
+ busyBackup.value = false
+ }
+}
+
+async function onBackupFileSelect(e) {
+ const file = e.target.files[0]
+ if (!file) return
+ busyBackup.value = true
+ result.value = null
+ try {
+ const summary = await store.importDataDump(file, backupPassphrase.value)
+ result.value = {
+ total: summary.importedContacts + summary.importedRelations,
+ created: summary.importedContacts,
+ skipped: 0,
+ errors: [],
+ }
+ } catch (error) {
+ result.value = { error: error?.message || 'Ошибка импорта бэкапа' }
+ } finally {
+ busyBackup.value = false
+ e.target.value = ''
+ }
+}