Add local-first SPA architecture and containerized production deploy.

Move data access to use-cases and IndexedDB repositories with optional
remote fallback, changelog/sync ports, and encrypted local backup.
Add production Docker Compose (nginx frontend + optional backend) and
Apache host proxy configuration for deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:08:02 +03:00
co-authored by Cursor
parent b668ee8507
commit 5e0af42fce
37 changed files with 1117 additions and 51 deletions
@@ -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: {},
})
}
@@ -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()
}
@@ -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 }
}
@@ -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: {},
})
}
+15
View File
@@ -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 }
}