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:
@@ -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: {},
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user