624 lines
26 KiB
TypeScript
624 lines
26 KiB
TypeScript
import {
|
|
pgTable,
|
|
pgEnum,
|
|
uuid,
|
|
text,
|
|
timestamp,
|
|
jsonb,
|
|
integer,
|
|
numeric,
|
|
uniqueIndex,
|
|
index,
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
// --- Business Engine (blueprint sectiunea 5, 12): companii, tranzactii, documente, taskuri ---
|
|
// Organizations e sursa de adevar in CEO Postgres pentru companii private (blueprint sectiunea 9);
|
|
// ERPNext devine sursa de adevar doar pentru "Accounting official", dupa activare -- vezi
|
|
// Blueprint v4.0 sectiunea 9 si Anexa I decizia #3.
|
|
|
|
export const organizations = pgTable('organizations', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
name: text('name').notNull(),
|
|
legalName: text('legal_name'),
|
|
country: text('country'),
|
|
registryId: text('registry_id'),
|
|
domain: text('domain'),
|
|
externalIds: jsonb('external_ids').notNull().default({}),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
deletedAt: timestamp('deleted_at'),
|
|
version: integer('version').notNull().default(1),
|
|
});
|
|
|
|
export const transactionEvidenceStatus = pgEnum('transaction_evidence_status', [
|
|
'missing',
|
|
'partial',
|
|
'complete',
|
|
'not_required',
|
|
]);
|
|
|
|
// amounts in minor units (cents), niciodata float, per blueprint 12.1
|
|
export const transactions = pgTable('transactions', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
organizationId: uuid('organization_id').notNull(),
|
|
type: text('type').notNull(),
|
|
amountMinorUnits: numeric('amount_minor_units', { precision: 18, scale: 0 }).notNull(),
|
|
currency: text('currency').notNull(),
|
|
transactionDate: timestamp('transaction_date').notNull(),
|
|
evidenceStatus: transactionEvidenceStatus('evidence_status').notNull().default('missing'),
|
|
source: text('source'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
deletedAt: timestamp('deleted_at'),
|
|
version: integer('version').notNull().default(1),
|
|
});
|
|
|
|
export const documentClassification = pgEnum('document_classification', ['c0', 'c1', 'c2', 'c3', 'c4']);
|
|
|
|
// CEO OS pastreaza doar metadata/ownership/permission decisions (blueprint sectiunea 13);
|
|
// binarul si OCR-ul raman in Paperless-ngx, legate prin paperlessId
|
|
export const documents = pgTable('documents', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
organizationId: uuid('organization_id'),
|
|
ownerUserId: uuid('owner_user_id').notNull(),
|
|
classification: documentClassification('classification').notNull().default('c2'),
|
|
paperlessId: text('paperless_id'),
|
|
filename: text('filename').notNull(),
|
|
checksum: text('checksum'),
|
|
expiresAt: timestamp('expires_at'),
|
|
retentionPolicy: text('retention_policy'),
|
|
ocrStatus: text('ocr_status'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
deletedAt: timestamp('deleted_at'),
|
|
version: integer('version').notNull().default(1),
|
|
});
|
|
|
|
export const taskStatus = pgEnum('task_status', ['open', 'in_progress', 'blocked', 'done', 'cancelled']);
|
|
|
|
export const tasks = pgTable('tasks', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
ownerUserId: uuid('owner_user_id').notNull(),
|
|
title: text('title').notNull(),
|
|
priority: integer('priority').notNull().default(3),
|
|
dueAt: timestamp('due_at'),
|
|
sourceEntityType: text('source_entity_type'),
|
|
sourceEntityId: uuid('source_entity_id'),
|
|
status: taskStatus('status').notNull().default('open'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
deletedAt: timestamp('deleted_at'),
|
|
version: integer('version').notNull().default(1),
|
|
});
|
|
|
|
// --- Identity Engine (blueprint sectiunea 11, 8.2, 8.3) ---
|
|
|
|
export const tenants = pgTable('tenants', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
name: text('name').notNull(),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
});
|
|
|
|
// Tenant != Workspace. Tenantul e clientul izolat; workspace-ul e contextul
|
|
// efectiv de lucru DIN tenant (personal / familie / o anumita firma / comunitate).
|
|
// Un user poate avea roluri diferite in workspace-uri diferite ale aceluiasi tenant.
|
|
export const workspaceType = pgEnum('workspace_type', [
|
|
'personal',
|
|
'family',
|
|
'business',
|
|
'community',
|
|
'project',
|
|
]);
|
|
|
|
export const workspaces = pgTable(
|
|
'workspaces',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
name: text('name').notNull(),
|
|
type: workspaceType('type').notNull().default('business'),
|
|
/** Workspace-ul creat automat la onboarding; nu poate fi sters. */
|
|
isDefault: integer('is_default').notNull().default(0),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
createdBy: uuid('created_by'),
|
|
deletedAt: timestamp('deleted_at'),
|
|
},
|
|
(table) => [index('workspaces_tenant_idx').on(table.tenantId)],
|
|
);
|
|
|
|
export const membershipRole = pgEnum('membership_role', ['owner', 'admin', 'member']);
|
|
|
|
// user_id refera auth.users din Supabase Auth (8.1: "user identity si sessions" = Supabase Auth,
|
|
// nu duplicam identitatea aici, doar apartenenta la tenant + rol)
|
|
export const memberships = pgTable(
|
|
'memberships',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
userId: uuid('user_id').notNull(),
|
|
role: membershipRole('role').notNull().default('member'),
|
|
// NULL = membership la nivel de tenant (valabil in toate workspace-urile).
|
|
// Setat = rol doar in acel workspace, cum cere specul: acelasi user poate fi
|
|
// owner in Personal si accountant intr-un workspace de firma.
|
|
workspaceId: uuid('workspace_id'),
|
|
// Acces delegat cu expirare (ex. contabil pana la 31.12). NULL = fara limita.
|
|
validFrom: timestamp('valid_from'),
|
|
validUntil: timestamp('valid_until'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
},
|
|
(table) => [
|
|
// un user are cel mult un membership per tenant; lookup-ul din SessionGuard
|
|
// e pe exact aceasta pereche la fiecare request
|
|
uniqueIndex('memberships_tenant_user_uq').on(table.tenantId, table.userId),
|
|
index('memberships_user_idx').on(table.userId),
|
|
index('memberships_workspace_idx').on(table.workspaceId),
|
|
],
|
|
);
|
|
|
|
export const consentRecords = pgTable('consent_records', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
userId: uuid('user_id').notNull(),
|
|
purpose: text('purpose').notNull(),
|
|
grantedAt: timestamp('granted_at').defaultNow().notNull(),
|
|
revokedAt: timestamp('revoked_at'),
|
|
});
|
|
|
|
// --- Life Engine (blueprint sectiunea 5): obiective personale, rutine, KPI ---
|
|
|
|
export const goalStatus = pgEnum('goal_status', ['not_started', 'on_track', 'at_risk', 'achieved', 'abandoned']);
|
|
|
|
export const goals = pgTable('goals', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
ownerUserId: uuid('owner_user_id').notNull(),
|
|
horizon: text('horizon').notNull(),
|
|
metric: text('metric').notNull(),
|
|
target: text('target').notNull(),
|
|
milestones: jsonb('milestones').notNull().default([]),
|
|
status: goalStatus('status').notNull().default('not_started'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
deletedAt: timestamp('deleted_at'),
|
|
version: integer('version').notNull().default(1),
|
|
});
|
|
|
|
// --- Intelligence Engine (blueprint sectiunea 5): decizii, oportunitati, cereri AI ---
|
|
|
|
export const decisions = pgTable('decisions', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
ownerUserId: uuid('owner_user_id').notNull(),
|
|
context: text('context').notNull(),
|
|
options: jsonb('options').notNull().default([]),
|
|
assumptions: jsonb('assumptions').notNull().default([]),
|
|
evidence: jsonb('evidence').notNull().default([]),
|
|
selectedOption: text('selected_option'),
|
|
outcomeReview: text('outcome_review'),
|
|
reviewDueAt: timestamp('review_due_at'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
});
|
|
|
|
export const opportunities = pgTable('opportunities', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
source: text('source').notNull(),
|
|
entityType: text('entity_type'),
|
|
entityId: text('entity_id'),
|
|
valueRangeMin: numeric('value_range_min', { precision: 18, scale: 0 }),
|
|
valueRangeMax: numeric('value_range_max', { precision: 18, scale: 0 }),
|
|
probability: numeric('probability', { precision: 5, scale: 4 }),
|
|
nextAction: text('next_action'),
|
|
expiresAt: timestamp('expires_at'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
});
|
|
|
|
// context_manifest_hash leaga cererea de Context Manifest-ul trimis catre AI Gateway
|
|
// (blueprint sectiunea 14.1) -- payload-ul complet nu se stocheaza aici, doar hash-ul,
|
|
// pentru audit fara a pastra continut sensibil in Postgres
|
|
// costUsd e numeric(12,6), NU integer "minor units": costurile AI reale sunt
|
|
// fractiuni de cent (ex. $0.00007 per research brief draft) -- rotunjirea la
|
|
// cent ar sterge complet semnalul de cost. contextManifest se pastreaza integral
|
|
// (nu doar hash-ul) pentru audit -- blueprint 9 "AI audit: prompt template
|
|
// version, context manifest, cost si rezultat".
|
|
export const aiRequests = pgTable('ai_requests', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
requestedByUserId: uuid('requested_by_user_id').notNull(),
|
|
purpose: text('purpose').notNull(),
|
|
actionClass: text('action_class').notNull(),
|
|
contextManifestHash: text('context_manifest_hash').notNull(),
|
|
contextManifest: jsonb('context_manifest').notNull(),
|
|
model: text('model').notNull(),
|
|
templateVersion: text('template_version'),
|
|
costUsd: numeric('cost_usd', { precision: 12, scale: 6 }),
|
|
resultStatus: text('result_status').notNull().default('pending'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
completedAt: timestamp('completed_at'),
|
|
});
|
|
|
|
// --- Trust Engine (blueprint sectiunea 5): dovezi, observatii, reputation factors ---
|
|
|
|
// subiectul poate fi orice entitate (user, organization, device) -- identificat prin
|
|
// subjectType+subjectId in loc de FK rigid, ca sa ramana generic pe tot ce alimenteaza
|
|
// scoruri de incredere/reputatie
|
|
export const observations = pgTable('observations', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
subjectType: text('subject_type').notNull(),
|
|
subjectId: text('subject_id').notNull(),
|
|
metric: text('metric').notNull(),
|
|
value: text('value').notNull(),
|
|
unit: text('unit'),
|
|
source: text('source').notNull(),
|
|
observedAt: timestamp('observed_at').defaultNow().notNull(),
|
|
confidence: numeric('confidence', { precision: 5, scale: 4 }),
|
|
});
|
|
|
|
// --- Event Fabric / Outbox (blueprint sectiunea 9.1, principiul 2.1 "Events before intelligence") ---
|
|
|
|
// Event envelope complet (spec sectiunea 11). Evenimentele se numesc ca FAPTE
|
|
// petrecute (document.uploaded), niciodata ca instructiuni (process.document.now).
|
|
export const outboxEvents = pgTable(
|
|
'outbox_events',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
eventType: text('event_type').notNull(),
|
|
eventVersion: integer('event_version').notNull().default(1),
|
|
/** Cand s-a petrecut faptul (poate diferi de cand a fost scris randul). */
|
|
occurredAt: timestamp('occurred_at').defaultNow().notNull(),
|
|
actorId: uuid('actor_id'),
|
|
aggregateType: text('aggregate_type'),
|
|
/** Pastrat ca subject_id pentru compatibilitate cu dispatcher-ul existent. */
|
|
subjectId: uuid('subject_id'),
|
|
payload: jsonb('payload').notNull(),
|
|
/** Leaga toate operatiunile din acelasi workflow. */
|
|
correlationId: uuid('correlation_id'),
|
|
/** Ce comanda sau eveniment a produs acest eveniment. */
|
|
causationId: uuid('causation_id'),
|
|
/** C0-C4; impiedica distribuirea catre servicii neautorizate. */
|
|
classification: text('classification').notNull().default('c2'),
|
|
provenance: jsonb('provenance').notNull().default({}),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
processedAt: timestamp('processed_at'),
|
|
},
|
|
(table) => [index('outbox_unprocessed_idx').on(table.processedAt, table.createdAt)],
|
|
);
|
|
|
|
// --- Audit (blueprint sectiunea 31.4) ---
|
|
|
|
// --- Projection Kernel (spec Read Models sectiunile 2-6) -------------------
|
|
// Read models NU sunt sursa de adevar: pot fi sterse si reconstruite oricand
|
|
// din tabelele canonice. Userul nu le modifica direct.
|
|
|
|
/**
|
|
* Garantia de idempotency: acelasi eveniment procesat de doua ori de aceeasi
|
|
* proiectie nu produce efect dublu. Cheia unica e (proiectie, versiune, event).
|
|
*/
|
|
export const projectionProcessedEvents = pgTable(
|
|
'projection_processed_events',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
projectionName: text('projection_name').notNull(),
|
|
projectionVersion: integer('projection_version').notNull(),
|
|
eventId: uuid('event_id').notNull(),
|
|
processedAt: timestamp('processed_at').defaultNow().notNull(),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('projection_processed_uq').on(
|
|
table.projectionName,
|
|
table.projectionVersion,
|
|
table.eventId,
|
|
),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Checkpoint = optimizare (de unde reia scanarea), NU garantia de corectitudine.
|
|
* Corectitudinea vine din projection_processed_events: chiar daca checkpointul
|
|
* e in urma si se rescaneaza, evenimentele deja procesate sunt sarite.
|
|
*/
|
|
export const projectionCheckpoints = pgTable(
|
|
'projection_checkpoints',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
projectionName: text('projection_name').notNull(),
|
|
projectionVersion: integer('projection_version').notNull(),
|
|
lastEventAt: timestamp('last_event_at'),
|
|
lastEventId: uuid('last_event_id'),
|
|
/** active | paused | rebuilding | failed */
|
|
status: text('status').notNull().default('active'),
|
|
lastError: text('last_error'),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('projection_checkpoint_uq').on(table.projectionName, table.projectionVersion),
|
|
],
|
|
);
|
|
|
|
export const executiveDashboardProjection = pgTable(
|
|
'executive_dashboard_projection',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id').notNull(),
|
|
openTasksCount: integer('open_tasks_count').notNull().default(0),
|
|
overdueTasksCount: integer('overdue_tasks_count').notNull().default(0),
|
|
deadlinesNext7Days: integer('deadlines_next_7_days').notNull().default(0),
|
|
organizationsCount: integer('organizations_count').notNull().default(0),
|
|
segmentsCount: integer('segments_count').notNull().default(0),
|
|
researchBriefsCount: integer('research_briefs_count').notNull().default(0),
|
|
// Campurile de mai jos exista in spec dar depind de module neconstruite
|
|
// (Documents, Transactions, Approvals, Opportunities). Raman 0 explicit,
|
|
// ca sa nu para date reale cand modulele nu exista.
|
|
documentsMissingCount: integer('documents_missing_count').notNull().default(0),
|
|
transactionsUnclassifiedCount: integer('transactions_unclassified_count').notNull().default(0),
|
|
pendingApprovalsCount: integer('pending_approvals_count').notNull().default(0),
|
|
generatedAt: timestamp('generated_at').defaultNow().notNull(),
|
|
projectionVersion: integer('projection_version').notNull().default(1),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('executive_dashboard_tenant_ws_uq').on(table.tenantId, table.workspaceId),
|
|
],
|
|
);
|
|
|
|
// --- Notifications Engine (spec sectiunile 8-15) ---------------------------
|
|
// Regula centrala: NU o notificare per eveniment. Motorul decide daca merita
|
|
// notificat, cui, ce severitate, daca se agrega, ce canal si cand.
|
|
|
|
export const notificationSeverity = pgEnum('notification_severity', [
|
|
'CRITICAL',
|
|
'ACTION_REQUIRED',
|
|
'WARNING',
|
|
'INFORMATION',
|
|
'INTELLIGENCE',
|
|
'SYSTEM',
|
|
]);
|
|
|
|
export const notificationStatus = pgEnum('notification_status', [
|
|
'PENDING',
|
|
'SCHEDULED',
|
|
'DELIVERING',
|
|
'DELIVERED',
|
|
'READ',
|
|
'ACKNOWLEDGED',
|
|
'DISMISSED',
|
|
'FAILED',
|
|
'EXPIRED',
|
|
'ESCALATED',
|
|
]);
|
|
|
|
export const notifications = pgTable(
|
|
'notifications',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
recipientUserId: uuid('recipient_user_id').notNull(),
|
|
category: text('category').notNull(),
|
|
severity: notificationSeverity('severity').notNull(),
|
|
title: text('title').notNull(),
|
|
body: text('body'),
|
|
/** Fiecare notificare are eveniment-sursa (criteriu de acceptare #1). */
|
|
sourceEventId: uuid('source_event_id'),
|
|
sourceEntityType: text('source_entity_type'),
|
|
sourceEntityId: text('source_entity_id'),
|
|
actionUrl: text('action_url'),
|
|
/** Quick action: ce command executa, nu doar marcheaza notificarea. */
|
|
actionType: text('action_type'),
|
|
status: notificationStatus('status').notNull().default('PENDING'),
|
|
/** tenant + recipient + categorie + entitate: impiedica retrimiterea. */
|
|
deduplicationKey: text('deduplication_key').notNull(),
|
|
/** Mai multe evenimente similare intr-o singura notificare. */
|
|
aggregationKey: text('aggregation_key'),
|
|
aggregatedCount: integer('aggregated_count').notNull().default(1),
|
|
channels: jsonb('channels').notNull().default(['in_app']),
|
|
scheduledAt: timestamp('scheduled_at'),
|
|
expiresAt: timestamp('expires_at'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
readAt: timestamp('read_at'),
|
|
acknowledgedAt: timestamp('acknowledged_at'),
|
|
snoozedUntil: timestamp('snoozed_until'),
|
|
metadata: jsonb('metadata').notNull().default({}),
|
|
},
|
|
(table) => [
|
|
index('notifications_recipient_idx').on(table.recipientUserId, table.status),
|
|
index('notifications_tenant_idx').on(table.tenantId, table.createdAt),
|
|
index('notifications_dedup_idx').on(table.deduplicationKey, table.createdAt),
|
|
],
|
|
);
|
|
|
|
export const notificationPreferences = pgTable(
|
|
'notification_preferences',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
userId: uuid('user_id').notNull(),
|
|
/** NULL = se aplica tuturor categoriilor. */
|
|
category: text('category'),
|
|
enabled: integer('enabled').notNull().default(1),
|
|
minimumSeverity: notificationSeverity('minimum_severity').notNull().default('INFORMATION'),
|
|
/** Ore locale 0-23. Egale = fara quiet hours. */
|
|
quietHoursStart: integer('quiet_hours_start'),
|
|
quietHoursEnd: integer('quiet_hours_end'),
|
|
/** CRITICAL poate ignora quiet hours doar daca politica permite. */
|
|
criticalBypassesQuietHours: integer('critical_bypasses_quiet_hours').notNull().default(1),
|
|
timezone: text('timezone').notNull().default('Europe/Bucharest'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
},
|
|
(table) => [uniqueIndex('notification_prefs_uq').on(table.userId, table.tenantId, table.category)],
|
|
);
|
|
|
|
/** Idempotency: acelasi eveniment nu produce notificari duplicate. */
|
|
export const notificationProcessedEvents = pgTable(
|
|
'notification_processed_events',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
ruleId: text('rule_id').notNull(),
|
|
eventId: uuid('event_id').notNull(),
|
|
processedAt: timestamp('processed_at').defaultNow().notNull(),
|
|
},
|
|
(table) => [uniqueIndex('notification_processed_uq').on(table.ruleId, table.eventId)],
|
|
);
|
|
|
|
// --- Saga Manager (spec Sprint 4-6) ---------------------------------------
|
|
// O saga coordoneaza un proces cu mai multi pasi care NU incap intr-o singura
|
|
// tranzactie (apeluri externe, latenta, pasi care pot esua independent).
|
|
// Garantia nu e atomicitatea, ci: ori toti pasii reusesc, ori cei deja executati
|
|
// sunt compensati in ordine inversa.
|
|
|
|
export const sagaStatus = pgEnum('saga_status', [
|
|
'RUNNING',
|
|
'COMPLETED',
|
|
'COMPENSATING',
|
|
'COMPENSATED',
|
|
'FAILED',
|
|
'TIMED_OUT',
|
|
]);
|
|
|
|
export const sagaStepStatus = pgEnum('saga_step_status', [
|
|
'PENDING',
|
|
'RUNNING',
|
|
'COMPLETED',
|
|
'FAILED',
|
|
'COMPENSATED',
|
|
'SKIPPED',
|
|
]);
|
|
|
|
export const sagaInstances = pgTable(
|
|
'saga_instances',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
sagaName: text('saga_name').notNull(),
|
|
sagaVersion: integer('saga_version').notNull(),
|
|
status: sagaStatus('status').notNull().default('RUNNING'),
|
|
/** Indexul pasului curent in definitie. */
|
|
currentStep: integer('current_step').notNull().default(0),
|
|
/** Evenimentul care a pornit saga -- baza idempotentei la start. */
|
|
triggerEventId: uuid('trigger_event_id').notNull(),
|
|
correlationId: uuid('correlation_id'),
|
|
/** Starea acumulata, citita si scrisa de pasi. */
|
|
context: jsonb('context').notNull().default({}),
|
|
attempt: integer('attempt').notNull().default(0),
|
|
/** Cand poate fi reluata: backoff exponential dupa esec. */
|
|
nextAttemptAt: timestamp('next_attempt_at').defaultNow().notNull(),
|
|
/** Termen absolut pentru intreaga saga, nu doar pentru pasul curent. */
|
|
timeoutAt: timestamp('timeout_at'),
|
|
lastError: text('last_error'),
|
|
/**
|
|
* Lease de executie. Doua procese runner nu trebuie sa avanseze aceeasi
|
|
* saga simultan; randul se ia cu FOR UPDATE SKIP LOCKED, iar lockedUntil
|
|
* elibereaza saga daca procesul care o tinea a murit.
|
|
*/
|
|
lockedUntil: timestamp('locked_until'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
completedAt: timestamp('completed_at'),
|
|
},
|
|
(table) => [
|
|
// Un eveniment porneste o singura instanta dintr-o saga data.
|
|
uniqueIndex('saga_trigger_uq').on(table.sagaName, table.sagaVersion, table.triggerEventId),
|
|
index('saga_due_idx').on(table.status, table.nextAttemptAt),
|
|
index('saga_tenant_idx').on(table.tenantId, table.createdAt),
|
|
],
|
|
);
|
|
|
|
export const sagaSteps = pgTable(
|
|
'saga_steps',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
sagaInstanceId: uuid('saga_instance_id').notNull(),
|
|
stepName: text('step_name').notNull(),
|
|
/** Pozitia in definitie; determina si ordinea inversa a compensarii. */
|
|
sequence: integer('sequence').notNull(),
|
|
status: sagaStepStatus('status').notNull().default('PENDING'),
|
|
attempts: integer('attempts').notNull().default(0),
|
|
output: jsonb('output'),
|
|
lastError: text('last_error'),
|
|
startedAt: timestamp('started_at'),
|
|
completedAt: timestamp('completed_at'),
|
|
compensatedAt: timestamp('compensated_at'),
|
|
},
|
|
(table) => [
|
|
// Un pas apare o singura data per instanta: baza idempotentei la avans.
|
|
uniqueIndex('saga_step_uq').on(table.sagaInstanceId, table.sequence),
|
|
],
|
|
);
|
|
|
|
// Append-oriented (spec sectiunea 29): se scrie, nu se modifica si nu se sterge.
|
|
export const auditLog = pgTable(
|
|
'audit_log',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
/** user | agent | automation | connector | system */
|
|
actorType: text('actor_type').notNull().default('user'),
|
|
actorId: uuid('actor_id'),
|
|
action: text('action').notNull(),
|
|
resource: text('resource').notNull(),
|
|
reason: text('reason'),
|
|
/** Scopul declarat al operatiunii -- necesar pentru purpose limitation. */
|
|
purpose: text('purpose'),
|
|
changedFields: jsonb('changed_fields'),
|
|
beforeHash: text('before_hash'),
|
|
afterHash: text('after_hash'),
|
|
sessionId: text('session_id'),
|
|
correlationId: uuid('correlation_id'),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
},
|
|
(table) => [index('audit_tenant_created_idx').on(table.tenantId, table.createdAt)],
|
|
);
|
|
|
|
// --- Intelligence Engine / Faza A2 (blueprint sectiunea 10.3, 17 "Lead Intelligence") ---
|
|
// Apollo/ClickHouse raman sursa de adevar pentru datele B2B (blueprint 9: "Private data !=
|
|
// B2B intelligence"); aici pastram doar referinte (organization_id extern) si curatoria
|
|
// tenantului -- cautari salvate si dovezi de research, nu o copie a datasetului.
|
|
|
|
export const savedSegments = pgTable('saved_segments', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
createdByUserId: uuid('created_by_user_id').notNull(),
|
|
name: text('name').notNull(),
|
|
query: text('query').notNull(),
|
|
resultLimit: integer('result_limit').notNull().default(20),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
});
|
|
|
|
// summary e curatat manual de user in absenta AI Gateway-ului (blueprint sectiunea 14);
|
|
// schema e gandita ca AI Gateway sa poata popula acelasi camp mai tarziu fara migrare
|
|
export const researchBriefs = pgTable('research_briefs', {
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
tenantId: uuid('tenant_id').notNull(),
|
|
workspaceId: uuid('workspace_id'),
|
|
createdByUserId: uuid('created_by_user_id').notNull(),
|
|
organizationId: text('organization_id').notNull(),
|
|
organizationName: text('organization_name'),
|
|
title: text('title').notNull(),
|
|
summary: text('summary').notNull(),
|
|
sources: jsonb('sources').notNull().default([]),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
});
|