Adding working board
This commit is contained in:
+147
-133
@@ -1,10 +1,14 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js'
|
||||
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, Exhibit, ExhibitRelation, OrganizationKind, PartyKind, SourceFileType } from '../src/types.js'
|
||||
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
||||
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||
import type { ObjectStorage } from './objectStorage.js'
|
||||
|
||||
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer }
|
||||
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
|
||||
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
|
||||
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
||||
|
||||
export interface LevelRepository {
|
||||
@@ -16,17 +20,17 @@ export interface LevelRepository {
|
||||
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
|
||||
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
||||
resetLevel(levelId: string): Promise<CaseState | null>
|
||||
getAsset(assetId: string): Promise<AssetRecord | null>
|
||||
getAsset(assetId: string): Promise<AssetResponse | null>
|
||||
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
|
||||
}
|
||||
|
||||
type LevelRow = {
|
||||
id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
|
||||
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date
|
||||
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date; revision: string
|
||||
source_template_version_id: string | null
|
||||
}
|
||||
type ExhibitRow = {
|
||||
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: number; hidden: boolean
|
||||
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
|
||||
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
|
||||
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
|
||||
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||
@@ -48,24 +52,27 @@ function documentType(document: CaseDocument): SourceFileType {
|
||||
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
||||
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
||||
}
|
||||
function documentKind(type: SourceFileType) {
|
||||
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
||||
}
|
||||
|
||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository {
|
||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage): LevelRepository {
|
||||
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
||||
const result = await client.query<LevelRow>(`SELECT id, slug, board_id, title, subtitle, status,
|
||||
viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id
|
||||
FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug])
|
||||
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
|
||||
l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
|
||||
FROM osint.levels l JOIN osint.boards b ON b.id=l.board_id WHERE l.slug = $1${lock ? ' FOR UPDATE OF l,b' : ''}`, [slug])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async function createDefaultBoardViews(client: PoolClient, boardId: string) {
|
||||
const viewId = randomUUID()
|
||||
await client.query(`INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height)
|
||||
VALUES ($1,$2,'timeline','docked','bottom',112)`, [viewId, boardId])
|
||||
await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
|
||||
}
|
||||
|
||||
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
|
||||
const level = await findLevel(pool, slug)
|
||||
if (!level) return null
|
||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([
|
||||
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
|
||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([
|
||||
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
|
||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
|
||||
@@ -98,21 +105,23 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
|
||||
`SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
|
||||
JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
|
||||
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>(
|
||||
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
|
||||
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
|
||||
`SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1
|
||||
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
|
||||
pool.query<{ party_exhibit_id: string; alias: string }>(
|
||||
`SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
|
||||
WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]),
|
||||
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>(
|
||||
`SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1
|
||||
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
|
||||
`SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1
|
||||
ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
|
||||
pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]),
|
||||
pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>(
|
||||
`SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts
|
||||
WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
|
||||
pool.query<{ range_start: string; range_end: string }>(
|
||||
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]),
|
||||
pool.query<{ id: string; view_type_id: 'timeline'; placement_mode: 'docked' | 'canvas' | 'window'; dock_edge: 'top' | 'right' | 'bottom' | 'left' | null; xpos: number | null; ypos: number | null; width: number | null; height: number; z_index: number; visible: boolean; range_mode: 'auto' | 'fixed'; range_start: string | null; range_end: string | null }>(
|
||||
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
|
||||
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
|
||||
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
|
||||
])
|
||||
|
||||
const blocks = new Map<string, string[]>()
|
||||
@@ -123,43 +132,50 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
}])
|
||||
const metadata = new Map<string, Record<string, string>>()
|
||||
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
|
||||
const contained = new Map<string, string[]>()
|
||||
const eventEvidence = new Map<string, string[]>()
|
||||
for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id])
|
||||
const aliases = new Map<string, string[]>()
|
||||
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
|
||||
const partyEvidence = new Map<string, string[]>()
|
||||
for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id])
|
||||
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
|
||||
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
|
||||
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
|
||||
toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } }
|
||||
})
|
||||
const relations: ExhibitRelation[] = [
|
||||
...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
|
||||
toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
|
||||
...eventEvidenceResult.rows.map(row => ({ id: `supports:${row.event_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.event_exhibit_id,
|
||||
toExhibitId: row.evidence_exhibit_id, type: 'supports' as const, sortOrder: row.sort_order, note: row.note || undefined })),
|
||||
...partyEvidenceResult.rows.map(row => ({ id: `concerns:${row.party_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.party_exhibit_id,
|
||||
toExhibitId: row.evidence_exhibit_id, type: 'concerns' as const, sortOrder: row.sort_order, note: row.note || undefined })),
|
||||
...exhibitsResult.rows.flatMap(row => row.source_document_id ? [{ id: `source:${row.id}`, fromExhibitId: row.id, toExhibitId: row.source_document_id,
|
||||
type: 'source' as const, sourceRegionId: row.source_region_key || undefined, sortOrder: 0 }] : []),
|
||||
]
|
||||
const base = (row: ExhibitRow) => ({ id: row.id, title: row.title, x: row.xpos, y: row.ypos, width: row.width, height: row.height,
|
||||
rotation: row.rotation, zIndex: row.z_index, hidden: row.hidden })
|
||||
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
|
||||
const type = row.document_type_id || 'file'
|
||||
const publishedAt = row.published_at?.toISOString()
|
||||
return { id: row.id, title: row.title, kind: documentKind(type), date: publishedAt?.slice(0, 10) || '', publishedAt,
|
||||
return { ...base(row), type: 'document', publishedAt,
|
||||
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
||||
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
||||
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
||||
})
|
||||
const evidence: Evidence[] = exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden).map(row => ({
|
||||
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
|
||||
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
|
||||
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
|
||||
supportingEvidenceIds: eventEvidence.get(row.id) || [],
|
||||
partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined,
|
||||
aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [],
|
||||
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
|
||||
}))
|
||||
const evidence: Evidence[] = []
|
||||
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) {
|
||||
const common = { ...base(row), title: row.title, content: row.content }
|
||||
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
|
||||
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
||||
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
|
||||
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
|
||||
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
|
||||
}
|
||||
const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index,
|
||||
placement: row.placement_mode === 'docked'
|
||||
? { mode: 'docked', dockEdge: row.dock_edge || 'bottom', size: row.height }
|
||||
: { mode: row.placement_mode, x: row.xpos || 0, y: row.ypos || 0, width: row.width || 900, height: row.height },
|
||||
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
|
||||
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
||||
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
|
||||
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
|
||||
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id,
|
||||
return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
|
||||
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
|
||||
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
||||
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||
timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined,
|
||||
views, revision: Number(level.revision),
|
||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||
}
|
||||
@@ -178,27 +194,15 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
}
|
||||
|
||||
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
|
||||
const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id')))
|
||||
const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
|
||||
const allIds = [...documentIds, ...evidenceIds]
|
||||
if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another exhibit')
|
||||
|
||||
const relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({
|
||||
id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index,
|
||||
})))
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
for (const relation of relationList.filter(item => item.type === 'contains')) {
|
||||
positions.set(relation.toWidgetId, { x: Number(relation.config?.x ?? 100), y: Number(relation.config?.y ?? 100) })
|
||||
}
|
||||
const existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id])
|
||||
for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos })
|
||||
if (!Array.isArray(state.exhibits) || !Array.isArray(state.views)) throw new Error('Level state must contain exhibits and views')
|
||||
const documents = state.exhibits.filter(isDocumentExhibit)
|
||||
const evidence = state.exhibits.filter((exhibit): exhibit is Evidence => !isDocumentExhibit(exhibit))
|
||||
const documentIds = new Set(documents.map(document => requireUuid(document.id, 'Document id')))
|
||||
const evidenceIds = new Set(evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
|
||||
const allIds = state.exhibits.map(exhibit => exhibit.id)
|
||||
if (new Set(allIds).size !== allIds.length) throw new Error('Exhibit ids must be unique within a board')
|
||||
const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>(
|
||||
'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind]))
|
||||
const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>(
|
||||
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id])
|
||||
const existingTimeline = existingTimelineResult.rows[0]
|
||||
? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end }
|
||||
: null
|
||||
|
||||
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
|
||||
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
|
||||
@@ -208,7 +212,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
|
||||
@@ -219,14 +223,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
|
||||
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
|
||||
|
||||
for (const [index, document] of state.documents.entries()) {
|
||||
const position = positions.get(document.id) || { x: 100, y: 100 }
|
||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||
VALUES ($1,$2,'document',$3,$4,174,145,$5,FALSE)
|
||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id='document',xpos=$3,ypos=$4,width=174,height=145,z_index=$5,hidden=FALSE,updated_at=NOW()`,
|
||||
[document.id, level.board_id, position.x, position.y, index])
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at)
|
||||
VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)])
|
||||
for (const exhibit of state.exhibits) {
|
||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
|
||||
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
|
||||
}
|
||||
for (const document of documents) {
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title,
|
||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
|
||||
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
|
||||
for (const [sortOrder, content] of document.body.entries()) await client.query(
|
||||
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
|
||||
@@ -234,22 +240,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
`INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
|
||||
}
|
||||
for (const [index, exhibit] of state.evidence.entries()) {
|
||||
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
|
||||
const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note'
|
||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE)
|
||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`,
|
||||
[exhibit.id, level.board_id, canonicalType, exhibit.x, exhibit.y, exhibit.width, state.documents.length + index])
|
||||
if (canonicalType === 'folder') await client.query(
|
||||
for (const exhibit of evidence) {
|
||||
if (isFolderExhibit(exhibit)) await client.query(
|
||||
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
||||
[exhibit.id, exhibit.title, exhibit.content, Boolean(exhibit.config?.open)])
|
||||
if (canonicalType === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
|
||||
if (canonicalType === 'event') await client.query(
|
||||
[exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
|
||||
if (exhibit.type === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
|
||||
if (isEventExhibit(exhibit)) await client.query(
|
||||
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
|
||||
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
|
||||
if (canonicalType === 'party') {
|
||||
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person'
|
||||
if (isPartyExhibit(exhibit)) {
|
||||
const partyKind = exhibit.partyKind
|
||||
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
|
||||
[exhibit.id, partyKind, exhibit.title, exhibit.content])
|
||||
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
|
||||
@@ -260,51 +260,57 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
}
|
||||
}
|
||||
|
||||
for (const relation of relationList.filter(item => item.type === 'contains')) {
|
||||
if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit')
|
||||
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
|
||||
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
|
||||
}
|
||||
for (const event of state.evidence.filter(item => item.type === 'event')) {
|
||||
for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) {
|
||||
if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit')
|
||||
await client.query(`INSERT INTO osint.event_evidence
|
||||
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||
[level.board_id, event.id, evidenceId, sortOrder])
|
||||
}
|
||||
}
|
||||
for (const party of state.evidence.filter(item => item.type === 'party')) {
|
||||
for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) {
|
||||
if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit')
|
||||
await client.query(`INSERT INTO osint.party_evidence
|
||||
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||
[level.board_id, party.id, evidenceId, sortOrder])
|
||||
for (const relation of state.relations) {
|
||||
if (!allIds.includes(relation.fromExhibitId) || !allIds.includes(relation.toExhibitId) || relation.fromExhibitId === relation.toExhibitId) throw new Error('Relation references an unknown or identical exhibit')
|
||||
if (relation.type === 'contains') await client.query(`INSERT INTO osint.folder_memberships
|
||||
(board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder])
|
||||
if (relation.type === 'supports') await client.query(`INSERT INTO osint.event_evidence
|
||||
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
|
||||
if (relation.type === 'concerns') await client.query(`INSERT INTO osint.party_evidence
|
||||
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
|
||||
if (relation.type === 'source') {
|
||||
if (!documentIds.has(relation.toExhibitId)) throw new Error('Exhibit source must reference a document')
|
||||
let regionId: string | null = null
|
||||
if (relation.sourceRegionId) {
|
||||
const region = await client.query<{ id: string }>(
|
||||
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [relation.toExhibitId, relation.sourceRegionId])
|
||||
regionId = region.rows[0]?.id || null
|
||||
}
|
||||
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
|
||||
[relation.fromExhibitId, relation.toExhibitId, regionId])
|
||||
}
|
||||
}
|
||||
for (const connection of state.connections) {
|
||||
requireUuid(connection.id, 'Connection id')
|
||||
if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit')
|
||||
if (!allIds.includes(connection.fromExhibitId) || !allIds.includes(connection.toExhibitId) || connection.fromExhibitId === connection.toExhibitId) throw new Error('Connection references an unknown or identical exhibit')
|
||||
const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
|
||||
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
|
||||
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
|
||||
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
|
||||
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0))))
|
||||
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset)
|
||||
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
|
||||
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
|
||||
}
|
||||
for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) {
|
||||
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
|
||||
let regionId: string | null = null
|
||||
if (exhibit.sourceRegionId) {
|
||||
const region = await client.query<{ id: string }>(
|
||||
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId])
|
||||
regionId = region.rows[0]?.id || null
|
||||
for (const view of state.views) {
|
||||
requireUuid(view.id, 'Board view id')
|
||||
const placement = view.placement
|
||||
const docked = placement.mode === 'docked'
|
||||
await client.query(`INSERT INTO osint.board_views
|
||||
(id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [view.id, level.board_id, view.type, placement.mode,
|
||||
placement.mode === 'docked' ? placement.dockEdge : null, placement.mode === 'docked' ? null : placement.x, placement.mode === 'docked' ? null : placement.y,
|
||||
placement.mode === 'docked' ? null : placement.width, placement.mode === 'docked' ? placement.size : placement.height, view.zIndex, view.visible])
|
||||
if (view.type === 'timeline') {
|
||||
if (view.rangeMode === 'fixed' && (!view.range || !timestamp(view.range.start) || !timestamp(view.range.end) || Date.parse(view.range.end) <= Date.parse(view.range.start))) throw new Error('Timeline end must be after timeline start')
|
||||
await client.query(`INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)`,
|
||||
[view.id, view.rangeMode, view.rangeMode === 'fixed' ? view.range!.start : null, view.rangeMode === 'fixed' ? view.range!.end : null])
|
||||
}
|
||||
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
|
||||
[exhibit.id, exhibit.sourceDocumentId, regionId])
|
||||
}
|
||||
const fields = new Map<string, string>()
|
||||
for (const document of state.documents) for (const key of Object.keys(document.metadata || {})) {
|
||||
for (const document of documents) for (const key of Object.keys(document.metadata || {})) {
|
||||
if (!fields.has(key)) {
|
||||
const fieldId = randomUUID(); fields.set(key, fieldId)
|
||||
await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
|
||||
@@ -313,14 +319,6 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
[document.id, fields.get(key), document.metadata[key]])
|
||||
}
|
||||
const brief = state.brief || { body: '', concepts: [] }
|
||||
const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange
|
||||
if (savedTimeline) {
|
||||
const start = timestamp(savedTimeline.start)
|
||||
const end = timestamp(savedTimeline.end)
|
||||
if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start')
|
||||
await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
|
||||
[level.board_id, savedTimeline.start, savedTimeline.end])
|
||||
}
|
||||
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
|
||||
for (const [sortOrder, concept] of brief.concepts.entries()) {
|
||||
requireUuid(concept.id, 'Brief concept id')
|
||||
@@ -346,6 +344,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
|
||||
await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[levelId, input.id, boardId, input.title, input.subtitle])
|
||||
await createDefaultBoardViews(client, boardId)
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
return (await assembleLevel(input.id))!
|
||||
@@ -444,8 +443,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
},
|
||||
async getAsset(assetId) {
|
||||
if (!uuidPattern.test(assetId)) return null
|
||||
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content FROM osint.assets WHERE id=$1', [assetId])
|
||||
return result.rows[0] || null
|
||||
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content,storage_provider,object_key FROM osint.assets WHERE id=$1', [assetId])
|
||||
const asset = result.rows[0]
|
||||
if (!asset) return null
|
||||
if (asset.storage_provider === 'postgres') {
|
||||
if (!asset.content) throw new Error(`PostgreSQL asset ${assetId} has no content`)
|
||||
return { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: Readable.from(asset.content) }
|
||||
}
|
||||
if (!asset.object_key) throw new Error(`Object asset ${assetId} has no object key`)
|
||||
const object = await objectStorage.getObject(asset.object_key)
|
||||
return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null
|
||||
},
|
||||
async uploadDocument(levelId, file) {
|
||||
const client = await pool.connect()
|
||||
@@ -455,21 +462,28 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
|
||||
const checksum = createHash('sha256').update(file.buffer).digest('hex')
|
||||
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
|
||||
(id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
|
||||
[candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum])
|
||||
let assetId = (await client.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2 FOR SHARE', [checksum,file.size])).rows[0]?.id
|
||||
if (!assetId) {
|
||||
const objectKey = `assets/${checksum.slice(0,2)}/${checksum}`
|
||||
const stored = await objectStorage.putObject(objectKey,file.buffer,file.mimetype || 'application/octet-stream')
|
||||
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
|
||||
(id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
|
||||
VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
|
||||
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
|
||||
[candidateAssetId,file.originalname,file.mimetype || 'application/octet-stream',file.size,checksum,objectStorage.bucket,objectKey,stored.etag || null])
|
||||
assetId = asset.rows[0].id
|
||||
}
|
||||
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
|
||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id])
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
|
||||
[exhibitId, fileType, asset.rows[0].id, file.originalname])
|
||||
[exhibitId, fileType, assetId, file.originalname])
|
||||
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
await client.query('COMMIT')
|
||||
return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [],
|
||||
assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size }
|
||||
return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
||||
fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size }
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user