Compare commits

...

92 commits

Author SHA1 Message Date
9844f0bd2c feat(CC-068): register DataSourcesModule 2026-08-02 12:18:08 +00:00
03ec11dca5 feat(CC-068): add DataSourcesModule 2026-08-02 12:18:08 +00:00
73976a8a7f feat(CC-068): add DataSourcesController (list, create, patch) 2026-08-02 12:18:07 +00:00
5dbe2112a9 feat(CC-068): add dataSources to schema 2026-08-02 12:18:06 +00:00
9cd14e34bb feat(CC-068): journal entry for migration 0014 2026-08-02 12:18:05 +00:00
fac78b309f feat(CC-068): add data_sources table (migration 0014) 2026-08-02 12:18:05 +00:00
8c0d366300 feat(CC-067): register AssumptionsModule + OutcomeReviewsModule 2026-08-02 12:13:01 +00:00
71b2ed43ee feat(CC-067): add OutcomeReviewsModule 2026-08-02 12:13:00 +00:00
c2a070b33f feat(CC-067): add OutcomeReviewsController (list, create, patch with rating/lessons) 2026-08-02 12:12:59 +00:00
a171888ae3 feat(CC-067): add AssumptionsModule 2026-08-02 12:12:59 +00:00
f80f8e6c48 feat(CC-067): add AssumptionsController (CRUD with confidence + status) 2026-08-02 12:12:58 +00:00
cbeb3b35a9 feat(CC-067): journal entry for migration 0013 2026-08-02 12:12:57 +00:00
6251954890 feat(CC-067): add assumptions + outcome_reviews tables (migration 0013) 2026-08-02 12:12:56 +00:00
5de297c848 feat(CC-066): register ActionPlansModule + ScenariosModule 2026-08-02 12:09:07 +00:00
e25a83e557 feat(CC-066): add ScenariosModule 2026-08-02 12:09:06 +00:00
988a7db958 feat(CC-066): add ScenariosController (CRUD + decision grouping) 2026-08-02 12:09:05 +00:00
a8d8e4fc32 feat(CC-066): add ActionPlansModule 2026-08-02 12:09:05 +00:00
9a67e49605 feat(CC-066): add ActionPlansController (CRUD) 2026-08-02 12:09:04 +00:00
f3278d8264 feat(CC-066): add actionPlans + scenarios to schema 2026-08-02 12:09:03 +00:00
f1cbe941e9 feat(CC-066): add journal entry for migration 0012 2026-08-02 12:09:01 +00:00
641c68ed91 feat(CC-066): add action_plans and scenarios tables (migration 0012) 2026-08-02 12:09:00 +00:00
972a0f6f9d feat(CC-065): extend global search to include contacts, risks, projects, contracts, obligations, pipeline 2026-08-01 21:29:51 +00:00
e368f05ca4 feat(CC-064): register ContractsModule, ObligationsModule, PipelineModule 2026-08-01 21:24:25 +00:00
71d06cebfb feat(CC-064): add PipelineModule 2026-08-01 21:24:24 +00:00
bc4ee37359 feat(CC-064): add PipelineController (sales pipeline deals with stage/probability) 2026-08-01 21:24:23 +00:00
d48bc25b46 feat(CC-064): add ObligationsModule 2026-08-01 21:24:22 +00:00
1758150ceb feat(CC-064): add ObligationsController (deadlines & obligations with risk level) 2026-08-01 21:24:22 +00:00
9953cce2d4 feat(CC-064): add ContractsModule 2026-08-01 21:24:21 +00:00
76cf59d82e feat(CC-064): add ContractsController (CRUD contracts with parties/status) 2026-08-01 21:24:20 +00:00
c3ecab8259 feat(CC-064): add contracts, obligations, pipelineDeals to Drizzle schema 2026-08-01 21:24:20 +00:00
035b71108b feat(CC-064): add contracts, obligations, pipeline_deals migration 2026-08-01 21:24:19 +00:00
e2756ffe79 chore(CC-064): add 0011_contracts_pipeline to migration journal 2026-08-01 21:24:18 +00:00
b4417ab103 feat(CC-063): register ContactsModule, RisksModule, ProjectsModule 2026-08-01 21:20:55 +00:00
0488e0f426 feat(CC-063): add ProjectsModule 2026-08-01 21:20:54 +00:00
9f40a1b9b8 feat(CC-063): add ProjectsController (CRUD projects with status/priority/tags) 2026-08-01 21:20:53 +00:00
f1ac86ea9c feat(CC-063): add RisksModule 2026-08-01 21:20:53 +00:00
de947d343f feat(CC-063): add RisksController (risk register with probability/impact/status) 2026-08-01 21:20:52 +00:00
867125c60a feat(CC-063): add ContactsModule 2026-08-01 21:20:51 +00:00
752f3c0326 feat(CC-063): add ContactsController (CRUD contacts with consent status) 2026-08-01 21:20:51 +00:00
2928271b44 feat(CC-063): add contacts, risks, projects to Drizzle schema 2026-08-01 21:20:50 +00:00
da8ac3359a feat(CC-063): add contacts, risks, projects tables migration 2026-08-01 21:20:49 +00:00
080769998a chore(CC-063): add 0010_ceo_os_features to migration journal 2026-08-01 21:20:48 +00:00
1fc75369c0 feat(CC-061): register DocumentsModule in AppModule 2026-08-01 21:13:34 +00:00
902a0d0748 feat(CC-061): add DocumentsModule 2026-08-01 21:13:33 +00:00
47b85203bd feat(CC-061): add DocumentsController (GET /documents with classification/ocrStatus filters) 2026-08-01 21:13:32 +00:00
c5d256ea78 feat(CC-061): add PATCH /ai-requests/:id/status and status filter on list endpoint 2026-08-01 21:13:32 +00:00
fe1bdbbbe0 feat(CC-060): register ExportModule in AppModule 2026-08-01 21:09:52 +00:00
1358b5d4dd feat(CC-060): add ExportModule 2026-08-01 21:09:51 +00:00
eca3f4a48b feat(CC-060): add ExportController (GET /export/data, POST /export/deletion-request) 2026-08-01 21:09:50 +00:00
8f69f9d95c feat(CC-058): add GET /ai-requests/:id with full contextManifest for transparency viewer 2026-08-01 21:01:56 +00:00
9d58aa90be feat(CC-057): register ConsentsModule in AppModule 2026-08-01 21:00:09 +00:00
18e427bde6 feat(CC-057): add ConsentsModule 2026-08-01 21:00:08 +00:00
497aaa919b feat(CC-057): add ConsentsController (grant/revoke/list consent records per purpose) 2026-08-01 21:00:08 +00:00
c8a517cd2d feat(CC-056): register SearchModule in AppModule 2026-08-01 20:55:51 +00:00
0a5ae5d483 feat(CC-056): add SearchModule 2026-08-01 20:55:51 +00:00
67cfd7cb12 feat(CC-056): add SearchController — unified ILIKE search across orgs/tasks/goals/decisions/research/segments 2026-08-01 20:55:50 +00:00
ff68bb2ca5 feat(CC-055): add GET /decisions/:id + expand PATCH to update context/options/assumptions/reviewDueAt 2026-08-01 20:51:20 +00:00
2cf220133f fix(migration): catch already-exists errors when schema pre-initialized via drizzle push 2026-08-01 20:20:25 +00:00
54a0c50bbc fix(TS2564): add ! to required fields in FinancialSignalItemDto + IngestSignalsDto 2026-08-01 20:16:11 +00:00
80eb62bca7 fix(TS2564): add ! to horizon/metric/target in CreateGoalDto 2026-08-01 20:16:10 +00:00
7240fb5805 fix(TS2564): add ! to title in CreateTaskDto 2026-08-01 20:16:09 +00:00
ed6f96fc46 fix(TS2564): add ! to context in CreateDecisionDto 2026-08-01 20:16:09 +00:00
dc13a487b5 fix(TS2564): add ! to required DTO properties in ExternalDataController 2026-08-01 20:16:08 +00:00
d46b38c216 feat(CC-054): add country+type params to legislation/ask endpoint 2026-08-01 19:00:29 +00:00
2e7e0c85ea feat(CC-054): smart AnythingLLM routing by country/type (RO/DE/FR/UK/EU) 2026-08-01 19:00:28 +00:00
1570e69b2d feat(CC-054): register ExternalDataModule in AppModule 2026-08-01 12:32:06 +00:00
925ce11a06 feat(CC-054): add @Public ingest endpoint with X-Ingest-Key auth 2026-08-01 12:32:04 +00:00
f52fe6d709 feat(CC-054): add ExternalDataModule 2026-08-01 12:32:03 +00:00
6316717850 feat(CC-054): add ExternalDataController (legislation search/ask, capability) 2026-08-01 12:32:03 +00:00
aefd370d78 feat(CC-054): add ExternalDataService (legislation + AnythingLLM + capability) 2026-08-01 12:32:02 +00:00
3a39adb4bc feat(cc-053): BriefingModule 2026-08-01 10:43:38 +00:00
7f6599d3f8 feat(cc-053): BriefingController — aggregate daily briefing 2026-08-01 10:43:37 +00:00
835b4a6574 feat(cc-053): TasksModule 2026-08-01 10:43:36 +00:00
929f04f9a6 feat(cc-053): TasksController — taskuri si operatiuni 2026-08-01 10:43:36 +00:00
93e76d4487 feat(cc-053): DecisionsModule 2026-08-01 10:43:35 +00:00
ae7b1d63b1 feat(cc-053): DecisionsController — log decizii 2026-08-01 10:43:34 +00:00
b0415c6e76 feat(cc-053): GoalsModule 2026-08-01 10:43:34 +00:00
04f142e7ea feat(cc-053): GoalsController — CRUD pentru obiective OKR 2026-08-01 10:43:33 +00:00
871dac9f0a feat(cc-052): register FinancialIntelligenceModule 2026-08-01 10:32:05 +00:00
0cd84f18c4 feat(cc-052): FinancialIntelligenceModule 2026-08-01 10:32:04 +00:00
0b2c515fe7 feat(cc-052): IngestSignalsDto 2026-08-01 10:32:04 +00:00
ebbbff123f feat(cc-052): FinancialIntelligenceController 2026-08-01 10:32:03 +00:00
b769613e49 feat(cc-052): copy drizzle/ migrations into final Docker image 2026-08-01 10:32:02 +00:00
c923d6a042 feat(cc-052): run drizzle migrations at startup 2026-08-01 10:32:02 +00:00
2408925264 feat(cc-052): add financialSignals table to schema 2026-08-01 10:32:01 +00:00
46a152dd13 feat(cc-052): add 0009_financial_intelligence to drizzle journal 2026-08-01 10:32:00 +00:00
ae7be88829 feat(cc-052): migration 0009 — financial_signals table 2026-08-01 10:31:59 +00:00
53eef93e84 feat(cc-050): register AiRequestsModule in AppModule 2026-08-01 09:57:54 +00:00
a08e43aedb feat(cc-050): add AiRequestsModule 2026-08-01 09:57:53 +00:00
ddb39df95f feat(cc-050): add AiRequests controller (list + costs) 2026-08-01 09:57:52 +00:00
dccd0bbffa feat(audit): register AuditLogController in AuditModule 2026-07-31 17:12:00 +00:00
af81d10242 feat(audit): expose GET /v1/audit-log endpoint (last N entries, tenant-scoped) 2026-07-31 17:11:59 +00:00
59 changed files with 2602 additions and 97 deletions

View file

@ -11,9 +11,10 @@ ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
RUN chown -R node:node /app
USER node
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3001/health || exit 1
CMD ["node", "dist/main"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,26 @@
-- Migration 0009: Financial Intelligence — market signals table
-- Stores macro/FX/rates/news signals ingested from n8n (World Bank, IMF, Eurostat, DBnomics)
CREATE TABLE IF NOT EXISTS "financial_signals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"tenant_id" uuid,
"category" text NOT NULL DEFAULT 'macro',
"region" text NOT NULL DEFAULT 'global',
"niche" text,
"source" text NOT NULL,
"indicator_code" text,
"title" text NOT NULL,
"summary" text NOT NULL,
"raw_value" numeric(18, 4),
"change_percent" numeric(10, 4),
"unit" text,
"severity" text NOT NULL DEFAULT 'info',
"published_at" timestamp NOT NULL DEFAULT now(),
"expires_at" timestamp,
"created_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "financial_signals_published_idx"
ON "financial_signals" ("category", "published_at" DESC);
CREATE INDEX IF NOT EXISTS "financial_signals_tenant_idx"
ON "financial_signals" ("tenant_id", "published_at" DESC);

View file

@ -0,0 +1,77 @@
-- CEO OS: Contacts (CRM), Risks (Risk Register), Projects
-- Migration 0010_ceo_os_features
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "contacts" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"full_name" text NOT NULL,
"email" text,
"phone" text,
"role" text,
"linkedin_url" text,
"notes" text,
"tags" text[] DEFAULT '{}' NOT NULL,
"source" text,
"consent_status" text DEFAULT 'unknown' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "risks" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"title" text NOT NULL,
"description" text,
"category" text,
"probability" text DEFAULT 'medium' NOT NULL,
"impact" text DEFAULT 'medium' NOT NULL,
"risk_score" integer GENERATED ALWAYS AS (
CASE probability
WHEN 'low' THEN 1
WHEN 'medium' THEN 2
WHEN 'high' THEN 3
WHEN 'critical' THEN 4
ELSE 2
END *
CASE impact
WHEN 'low' THEN 1
WHEN 'medium' THEN 2
WHEN 'high' THEN 3
WHEN 'critical' THEN 4
ELSE 2
END
) STORED,
"status" text DEFAULT 'open' NOT NULL,
"mitigation" text,
"owner" text,
"decision_id" uuid,
"review_due_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "projects" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"name" text NOT NULL,
"description" text,
"status" text DEFAULT 'planning' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"start_date" date,
"due_date" date,
"budget_minor_units" bigint,
"currency" text DEFAULT 'RON',
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);

View file

@ -0,0 +1,61 @@
-- CEO OS: Contracts, Obligations, Pipeline Deals
-- Migration 0011_contracts_pipeline
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "contracts" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"title" text NOT NULL,
"contract_type" text NOT NULL DEFAULT 'other',
"status" text NOT NULL DEFAULT 'draft',
"parties" text[] DEFAULT '{}' NOT NULL,
"start_date" date,
"end_date" date,
"value_minor_units" bigint,
"currency" text DEFAULT 'RON',
"notes" text,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "obligations" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"title" text NOT NULL,
"description" text,
"category" text NOT NULL DEFAULT 'contractual',
"status" text NOT NULL DEFAULT 'pending',
"due_date" date,
"owner" text,
"contract_id" uuid,
"evidence_url" text,
"risk_level" text NOT NULL DEFAULT 'medium',
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_deals" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"contact_id" uuid,
"title" text NOT NULL,
"stage" text NOT NULL DEFAULT 'identified',
"value_minor_units" bigint,
"currency" text DEFAULT 'RON',
"probability" integer DEFAULT 50,
"expected_close_date" date,
"notes" text,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);

View file

@ -0,0 +1,36 @@
-- CC-066: action_plans + scenarios
CREATE TABLE IF NOT EXISTS "action_plans" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"title" text NOT NULL,
"description" text,
"status" text DEFAULT 'draft' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"owner" text,
"due_date" date,
"completed_at" timestamp,
"decision_id" uuid,
"goal_id" uuid,
"project_id" uuid,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
CREATE TABLE IF NOT EXISTS "scenarios" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"decision_id" uuid,
"title" text NOT NULL,
"description" text,
"probability" text DEFAULT 'medium' NOT NULL,
"outcome" text,
"financial_impact_minor_units" numeric(15,0),
"financial_impact_currency" text DEFAULT 'RON' NOT NULL,
"impact_direction" text DEFAULT 'neutral' NOT NULL,
"status" text DEFAULT 'hypothetical' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,31 @@
-- CC-067: assumptions + outcome_reviews
CREATE TABLE IF NOT EXISTS "assumptions" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"decision_id" uuid,
"statement" text NOT NULL,
"source" text,
"confidence" text DEFAULT 'medium' NOT NULL,
"status" text DEFAULT 'unverified' NOT NULL,
"review_date" date,
"evidence_url" text,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS "outcome_reviews" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"decision_id" uuid,
"title" text NOT NULL,
"period_start" date,
"period_end" date,
"actual_outcome" text,
"was_successful" boolean,
"lessons_learned" text,
"next_steps" text,
"rating" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,15 @@
-- CC-068: data_sources registry
CREATE TABLE IF NOT EXISTS "data_sources" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"name" text NOT NULL,
"source_type" text DEFAULT 'manual' NOT NULL,
"description" text,
"connection_info" text,
"status" text DEFAULT 'active' NOT NULL,
"last_sync_at" timestamp,
"record_count" integer,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -57,6 +57,48 @@
"when": 1785331800000,
"tag": "0007_saga_manager",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1785580325133,
"tag": "0009_financial_intelligence",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1785680325133,
"tag": "0010_ceo_os_features",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785780000000,
"tag": "0011_contracts_pipeline",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1785780325133,
"tag": "0012_action_plans_scenarios",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1785880325133,
"tag": "0013_assumptions_outcomes",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1785980325133,
"tag": "0014_data_sources",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,91 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { actionPlans } from '../db/schema';
class CreateActionPlanDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['draft','active','completed','cancelled']) status?: string;
@IsOptional() @IsIn(['low','medium','high','critical']) priority?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsUUID() goalId?: string;
@IsOptional() @IsUUID() projectId?: string;
}
class UpdateActionPlanDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['draft','active','completed','cancelled']) status?: string;
@IsOptional() @IsIn(['low','medium','high','critical']) priority?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() dueDate?: string;
}
@Controller('action-plans')
export class ActionPlansController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('status') status?: string,
) {
const tid = session.tenantId;
const where = status
? and(eq(actionPlans.tenantId, tid), isNull(actionPlans.deletedAt), eq(actionPlans.status, status))
: and(eq(actionPlans.tenantId, tid), isNull(actionPlans.deletedAt));
return db.query.actionPlans.findMany({ where, orderBy: [desc(actionPlans.createdAt)], limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateActionPlanDto) {
const [row] = await db.insert(actionPlans).values({
tenantId: session.tenantId,
title: dto.title,
description: dto.description,
status: dto.status ?? 'draft',
priority: dto.priority ?? 'medium',
owner: dto.owner,
dueDate: dto.dueDate ?? null,
decisionId: dto.decisionId ?? null,
goalId: dto.goalId ?? null,
projectId: dto.projectId ?? null,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateActionPlanDto,
) {
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (dto.title !== undefined) updates.title = dto.title;
if (dto.description !== undefined) updates.description = dto.description;
if (dto.status !== undefined) {
updates.status = dto.status;
if (dto.status === 'completed') updates.completedAt = new Date();
}
if (dto.priority !== undefined) updates.priority = dto.priority;
if (dto.owner !== undefined) updates.owner = dto.owner;
if (dto.dueDate !== undefined) updates.dueDate = dto.dueDate;
const [row] = await db.update(actionPlans)
.set(updates)
.where(and(eq(actionPlans.id, id), eq(actionPlans.tenantId, session.tenantId)))
.returning();
return row;
}
@Delete(':id')
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(actionPlans)
.set({ deletedAt: new Date() })
.where(and(eq(actionPlans.id, id), eq(actionPlans.tenantId, session.tenantId)));
return { deleted: true };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ActionPlansController } from './action-plans.controller';
@Module({ controllers: [ActionPlansController] })
export class ActionPlansModule {}

View file

@ -0,0 +1,124 @@
import { Body, Controller, Get, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, sum, count } from 'drizzle-orm';
import { IsIn, IsOptional } from 'class-validator';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { aiRequests } from '../db/schema';
class UpdateAiRequestStatusDto {
@IsIn(['completed', 'cancelled']) resultStatus!: 'completed' | 'cancelled';
@IsOptional() @IsString() rejectionReason?: string;
}
@Controller('ai-requests')
export class AiRequestsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
@Query('status') status?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '200', 10) || 200), 500);
const where = status
? and(eq(aiRequests.tenantId, session.tenantId), eq(aiRequests.resultStatus, status))
: eq(aiRequests.tenantId, session.tenantId);
return db.query.aiRequests.findMany({
where,
orderBy: desc(aiRequests.createdAt),
limit,
columns: {
contextManifest: false,
},
});
}
@Get('costs')
async costs(@CurrentSession() session: AuthenticatedSession) {
const [totals] = await db
.select({
totalCostUsd: sum(aiRequests.costUsd),
totalRequests: count(),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId));
const byModel = await db
.select({
model: aiRequests.model,
requests: count(),
costUsd: sum(aiRequests.costUsd),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId))
.groupBy(aiRequests.model);
const byPurpose = await db
.select({
purpose: aiRequests.purpose,
requests: count(),
costUsd: sum(aiRequests.costUsd),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId))
.groupBy(aiRequests.purpose);
const byStatus = await db
.select({
resultStatus: aiRequests.resultStatus,
requests: count(),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId))
.groupBy(aiRequests.resultStatus);
return {
totals: {
totalCostUsd: totals?.totalCostUsd ?? '0',
totalRequests: totals?.totalRequests ?? 0,
},
byModel,
byPurpose,
byStatus,
};
}
@Get(':id')
async getOne(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
) {
const record = await db.query.aiRequests.findFirst({
where: and(eq(aiRequests.id, id), eq(aiRequests.tenantId, session.tenantId)),
});
if (!record) {
throw new NotFoundException('AI request not found');
}
return record;
}
@Patch(':id/status')
async updateStatus(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAiRequestStatusDto,
) {
const record = await db.query.aiRequests.findFirst({
where: and(
eq(aiRequests.id, id),
eq(aiRequests.tenantId, session.tenantId),
),
columns: { id: true, resultStatus: true },
});
if (!record) throw new NotFoundException('AI request not found');
if (record.resultStatus !== 'pending') {
return { updated: false, reason: 'request is not pending' };
}
const [updated] = await db
.update(aiRequests)
.set({ resultStatus: dto.resultStatus, completedAt: new Date() })
.where(eq(aiRequests.id, id))
.returning({ id: aiRequests.id, resultStatus: aiRequests.resultStatus, completedAt: aiRequests.completedAt });
return { updated: true, record: updated };
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AiRequestsController } from './ai-requests.controller';
@Module({
controllers: [AiRequestsController],
})
export class AiRequestsModule {}

View file

@ -23,11 +23,27 @@ import { ProjectionsModule } from './projections/projections.module';
import { NotificationsModule } from './notifications/notifications.module';
import { SagasModule } from './sagas/sagas.module';
import { IntelligenceModule } from './intelligence/intelligence.module';
import { AiRequestsModule } from './ai-requests/ai-requests.module';
import { FinancialIntelligenceModule } from './financial-intelligence/financial-intelligence.module';
import { SegmentsModule } from './segments/segments.module';
import { ResearchBriefsModule } from './research-briefs/research-briefs.module';
import { BriefingModule } from './briefing/briefing.module';
import { ExternalDataModule } from './external-data/external-data.module';
import { SearchModule } from './search/search.module';
import { ConsentsModule } from './consents/consents.module';
import { ExportModule } from './export/export.module';
import { DocumentsModule } from './documents/documents.module';
import { ContactsModule } from './contacts/contacts.module';
import { RisksModule } from './risks/risks.module';
import { ProjectsModule } from './projects/projects.module';
import { ContractsModule } from './contracts/contracts.module';
import { ObligationsModule } from './obligations/obligations.module';
import { PipelineModule } from './pipeline/pipeline.module';
import { SessionGuard } from './auth/session.guard';
import { TenantGuard } from './auth/tenant.guard';
import { ActionPlansModule } from './action-plans/action-plans.module';
import { ScenariosModule } from './scenarios/scenarios.module';
function parseRedisConnection(redisUrl: string | undefined) {
if (!redisUrl) {
@ -41,6 +57,9 @@ function parseRedisConnection(redisUrl: string | undefined) {
};
}
import { AssumptionsModule } from './assumptions/assumptions.module';
import { OutcomeReviewsModule } from './outcome-reviews/outcome-reviews.module';
import { DataSourcesModule } from './data-sources/data-sources.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
@ -65,9 +84,22 @@ function parseRedisConnection(redisUrl: string | undefined) {
ObservationsModule,
OpportunitiesModule,
IntelligenceModule,
AiRequestsModule,
FinancialIntelligenceModule,
SegmentsModule,
ResearchBriefsModule,
BriefingModule,
ExternalDataModule,
SearchModule,
ConsentsModule,
ExportModule,
DocumentsModule,
ContactsModule,
RisksModule,
ProjectsModule,
ContractsModule,
ObligationsModule,
PipelineModule,
],
controllers: [HealthController],
providers: [

View file

@ -0,0 +1,82 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { assumptions } from '../db/schema';
class CreateAssumptionDto {
@IsString() statement!: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsIn(['low','medium','high']) confidence?: string;
@IsOptional() @IsString() reviewDate?: string;
@IsOptional() @IsString() notes?: string;
}
class UpdateAssumptionDto {
@IsOptional() @IsString() statement?: string;
@IsOptional() @IsIn(['low','medium','high']) confidence?: string;
@IsOptional() @IsIn(['unverified','confirmed','refuted','pending_review']) status?: string;
@IsOptional() @IsString() evidenceUrl?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() reviewDate?: string;
}
@Controller('assumptions')
export class AssumptionsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('decisionId') decisionId?: string,
@Query('status') status?: string,
) {
const tid = session.tenantId;
let where = eq(assumptions.tenantId, tid) as ReturnType<typeof eq>;
if (decisionId) where = and(where, eq(assumptions.decisionId, decisionId)) as typeof where;
if (status) where = and(where, eq(assumptions.status, status)) as typeof where;
return db.query.assumptions.findMany({ where, orderBy: [desc(assumptions.createdAt)], limit: 300 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateAssumptionDto) {
const [row] = await db.insert(assumptions).values({
tenantId: session.tenantId,
statement: dto.statement,
decisionId: dto.decisionId ?? null,
source: dto.source,
confidence: dto.confidence ?? 'medium',
reviewDate: dto.reviewDate ?? null,
notes: dto.notes,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAssumptionDto,
) {
const upd: Record<string, unknown> = { updatedAt: new Date() };
if (dto.statement !== undefined) upd.statement = dto.statement;
if (dto.confidence !== undefined) upd.confidence = dto.confidence;
if (dto.status !== undefined) upd.status = dto.status;
if (dto.evidenceUrl !== undefined) upd.evidenceUrl = dto.evidenceUrl;
if (dto.notes !== undefined) upd.notes = dto.notes;
if (dto.reviewDate !== undefined) upd.reviewDate = dto.reviewDate;
const [row] = await db.update(assumptions)
.set(upd)
.where(and(eq(assumptions.id, id), eq(assumptions.tenantId, session.tenantId)))
.returning();
return row;
}
@Delete(':id')
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.delete(assumptions)
.where(and(eq(assumptions.id, id), eq(assumptions.tenantId, session.tenantId)));
return { deleted: true };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { AssumptionsController } from './assumptions.controller';
@Module({ controllers: [AssumptionsController] })
export class AssumptionsModule {}

View file

@ -0,0 +1,22 @@
import { Controller, Get, Query } from '@nestjs/common';
import { desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { auditLog } from '../db/schema';
@Controller('audit-log')
export class AuditLogController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '100', 10) || 100), 500);
return db.query.auditLog.findMany({
where: eq(auditLog.tenantId, session.tenantId),
orderBy: desc(auditLog.createdAt),
limit,
});
}
}

View file

@ -1,9 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuditLogController } from './audit.controller';
import { AuditService } from './audit.service';
// Global: aproape fiecare modul de domeniu scrie audit; evitam importuri repetate.
@Global()
@Module({
controllers: [AuditLogController],
providers: [AuditService],
exports: [AuditService],
})

View file

@ -1,14 +1,123 @@
import { Controller, Get } from '@nestjs/common';
import { and, desc, eq, gt, gte, isNull, isNotNull, lt, lte, inArray, or } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { BriefingService } from './briefing.service';
import { db } from '../db/client';
import { goals, tasks, decisions, opportunities, financialSignals, notifications } from '../db/schema';
@Controller('briefing')
export class BriefingController {
constructor(private readonly briefingService: BriefingService) {}
@Get()
async today(@CurrentSession() session: AuthenticatedSession) {
const now = new Date();
const todayEnd = new Date(now);
todayEnd.setHours(23, 59, 59, 999);
const sevenDays = new Date(now.getTime() + 7 * 86400_000);
const tid = session.tenantId;
@Get('today')
today(@CurrentSession() session: AuthenticatedSession) {
return this.briefingService.today(session);
const [
atRiskGoals,
overdueTasks,
todayTasks,
pendingDecisions,
criticalSignals,
expiringOpps,
recentNotifications,
] = await Promise.all([
// Goals at risk
db.query.goals.findMany({
where: and(eq(goals.tenantId, tid), isNull(goals.deletedAt), eq(goals.status, 'at_risk')),
orderBy: [desc(goals.createdAt)],
limit: 5,
}),
// Overdue tasks
db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, tid),
isNull(tasks.deletedAt),
inArray(tasks.status, ['open', 'in_progress', 'blocked'] as any[]),
lt(tasks.dueAt, now),
),
orderBy: [desc(tasks.priority)],
limit: 10,
}),
// Tasks due today
db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, tid),
isNull(tasks.deletedAt),
inArray(tasks.status, ['open', 'in_progress'] as any[]),
gte(tasks.dueAt, now),
lte(tasks.dueAt, todayEnd),
),
orderBy: [desc(tasks.priority)],
limit: 10,
}),
// Decisions pending outcome review
db.query.decisions.findMany({
where: and(
eq(decisions.tenantId, tid),
isNotNull(decisions.reviewDueAt),
lte(decisions.reviewDueAt, now),
isNull(decisions.outcomeReview),
),
orderBy: [desc(decisions.reviewDueAt)],
limit: 5,
}),
// Critical financial signals (last 48h)
db.query.financialSignals.findMany({
where: and(
or(isNull(financialSignals.tenantId), eq(financialSignals.tenantId, tid)),
eq(financialSignals.severity, 'critical'),
gte(financialSignals.publishedAt, new Date(now.getTime() - 48 * 3600_000)),
or(isNull(financialSignals.expiresAt), gt(financialSignals.expiresAt, now)),
),
orderBy: [desc(financialSignals.publishedAt)],
limit: 5,
}),
// Opportunities expiring in 7 days
db.query.opportunities.findMany({
where: and(
eq(opportunities.tenantId, tid),
isNotNull(opportunities.expiresAt),
lte(opportunities.expiresAt, sevenDays),
gt(opportunities.expiresAt, now),
),
orderBy: [desc(opportunities.expiresAt)],
limit: 5,
}),
// Unread notifications (last 24h, max 10)
db.query.notifications.findMany({
where: and(
eq(notifications.tenantId, tid),
eq(notifications.recipientUserId, session.userId),
inArray(notifications.status, ['PENDING', 'DELIVERED'] as any[]),
gte(notifications.createdAt, new Date(now.getTime() - 24 * 3600_000)),
),
orderBy: [desc(notifications.createdAt)],
limit: 10,
}),
]);
const score = Math.max(
0,
100
- atRiskGoals.length * 15
- Math.min(overdueTasks.length * 5, 25)
- pendingDecisions.length * 10
- criticalSignals.length * 10,
);
return {
generatedAt: now.toISOString(),
score,
atRiskGoals,
overdueTasks,
todayTasks,
pendingDecisions,
criticalSignals,
expiringOpportunities: expiringOpps,
notifications: recentNotifications,
};
}
}

View file

@ -1,11 +1,5 @@
import { Module } from '@nestjs/common';
import { AiGatewayModule } from '../ai-gateway/ai-gateway.module';
import { BriefingController } from './briefing.controller';
import { BriefingService } from './briefing.service';
@Module({
imports: [AiGatewayModule],
controllers: [BriefingController],
providers: [BriefingService],
})
@Module({ controllers: [BriefingController] })
export class BriefingModule {}

View file

@ -0,0 +1,81 @@
import { Body, Controller, Get, IsString, Post } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { consentRecords } from '../db/schema';
class GrantConsentDto {
@IsString() purpose!: string;
}
class RevokeConsentDto {
@IsString() purpose!: string;
}
@Controller('consents')
export class ConsentsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession) {
return db.query.consentRecords.findMany({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
),
orderBy: [desc(consentRecords.grantedAt)],
});
}
@Post('grant')
async grant(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: GrantConsentDto,
) {
// Revoke any previous record for this purpose first
const existing = await db.query.consentRecords.findFirst({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
eq(consentRecords.purpose, dto.purpose),
isNull(consentRecords.revokedAt),
),
});
if (existing) {
return existing;
}
const [created] = await db
.insert(consentRecords)
.values({
tenantId: session.tenantId,
userId: session.userId,
purpose: dto.purpose,
grantedAt: new Date(),
})
.returning();
return created;
}
@Post('revoke')
async revoke(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: RevokeConsentDto,
) {
const active = await db.query.consentRecords.findFirst({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
eq(consentRecords.purpose, dto.purpose),
isNull(consentRecords.revokedAt),
),
});
if (!active) {
return { revoked: false, reason: 'no active consent for this purpose' };
}
const [updated] = await db
.update(consentRecords)
.set({ revokedAt: new Date() })
.where(eq(consentRecords.id, active.id))
.returning();
return { revoked: true, record: updated };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ConsentsController } from './consents.controller';
@Module({ controllers: [ConsentsController] })
export class ConsentsModule {}

View file

@ -0,0 +1,78 @@
import { Body, Controller, Delete, Get, HttpCode, IsArray, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { contacts } from '../db/schema';
class CreateContactDto {
@IsString() fullName!: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() role?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() linkedinUrl?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() consentStatus?: string;
}
class UpdateContactDto {
@IsOptional() @IsString() fullName?: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() role?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() linkedinUrl?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
@IsOptional() @IsString() consentStatus?: string;
}
@Controller('contacts')
export class ContactsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('limit') limitStr?: string) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '200', 10) || 200), 500);
return db.query.contacts.findMany({
where: and(eq(contacts.tenantId, session.tenantId), isNull(contacts.deletedAt)),
orderBy: desc(contacts.createdAt),
limit,
});
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateContactDto) {
const [contact] = await db.insert(contacts).values({
...dto,
tenantId: session.tenantId,
tags: dto.tags ?? [],
}).returning();
return contact;
}
@Get(':id')
async getOne(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
const contact = await db.query.contacts.findFirst({
where: and(eq(contacts.id, id), eq(contacts.tenantId, session.tenantId), isNull(contacts.deletedAt)),
});
if (!contact) throw new NotFoundException('Contact not found');
return contact;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContactDto) {
const [updated] = await db.update(contacts).set({ ...dto, updatedAt: new Date() })
.where(and(eq(contacts.id, id), eq(contacts.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Contact not found');
return updated;
}
@Delete(':id')
@HttpCode(204)
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(contacts).set({ deletedAt: new Date() })
.where(and(eq(contacts.id, id), eq(contacts.tenantId, session.tenantId)));
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ContactsController } from './contacts.controller';
@Module({ controllers: [ContactsController] })
export class ContactsModule {}

View file

@ -0,0 +1,70 @@
import { Body, Controller, Get, HttpCode, IsArray, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { contracts } from '../db/schema';
class CreateContractDto {
@IsString() title!: string;
@IsOptional() @IsString() contractType?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsArray() parties?: string[];
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() endDate?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
}
class UpdateContractDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() contractType?: string;
@IsOptional() @IsArray() parties?: string[];
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() endDate?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
}
@Controller('contracts')
export class ContractsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(contracts.tenantId, session.tenantId), isNull(contracts.deletedAt), eq(contracts.status, status))
: and(eq(contracts.tenantId, session.tenantId), isNull(contracts.deletedAt));
return db.query.contracts.findMany({ where, orderBy: desc(contracts.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateContractDto) {
const [contract] = await db.insert(contracts).values({
...dto, tenantId: session.tenantId, parties: dto.parties ?? [], tags: dto.tags ?? [],
}).returning();
return contract;
}
@Get(':id')
async getOne(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
const contract = await db.query.contracts.findFirst({
where: and(eq(contracts.id, id), eq(contracts.tenantId, session.tenantId), isNull(contracts.deletedAt)),
});
if (!contract) throw new NotFoundException('Contract not found');
return contract;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDto) {
const [updated] = await db.update(contracts).set({ ...dto, updatedAt: new Date() })
.where(and(eq(contracts.id, id), eq(contracts.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Contract not found');
return updated;
}
@HttpCode(204) @Post(':id/archive')
async archive(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(contracts).set({ deletedAt: new Date() })
.where(and(eq(contracts.id, id), eq(contracts.tenantId, session.tenantId)));
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ContractsController } from './contracts.controller';
@Module({ controllers: [ContractsController] })
export class ContractsModule {}

View file

@ -0,0 +1,71 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsInt, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { dataSources } from '../db/schema';
class CreateDataSourceDto {
@IsString() name!: string;
@IsOptional() @IsIn(['api','file','manual','database','integration','clickhouse','sheet']) sourceType?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() connectionInfo?: string;
@IsOptional() @IsInt() @Type(() => Number) recordCount?: number;
}
class UpdateDataSourceDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['active','paused','error','archived']) status?: string;
@IsOptional() @IsInt() @Type(() => Number) recordCount?: number;
@IsOptional() @IsString() connectionInfo?: string;
}
@Controller('data-sources')
export class DataSourcesController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('status') status?: string,
) {
const tid = session.tenantId;
let where = eq(dataSources.tenantId, tid) as ReturnType<typeof eq>;
if (status) where = and(where, eq(dataSources.status, status)) as typeof where;
return db.query.dataSources.findMany({ where, orderBy: [desc(dataSources.createdAt)], limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateDataSourceDto) {
const [row] = await db.insert(dataSources).values({
tenantId: session.tenantId,
name: dto.name,
sourceType: dto.sourceType ?? 'manual',
description: dto.description,
connectionInfo: dto.connectionInfo,
recordCount: dto.recordCount ?? null,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDataSourceDto,
) {
const upd: Record<string, unknown> = { updatedAt: new Date() };
if (dto.name !== undefined) upd.name = dto.name;
if (dto.description !== undefined) upd.description = dto.description;
if (dto.status !== undefined) upd.status = dto.status;
if (dto.recordCount !== undefined) upd.recordCount = dto.recordCount;
if (dto.connectionInfo !== undefined) upd.connectionInfo = dto.connectionInfo;
if (dto.status === 'active') upd.lastSyncAt = new Date();
const [row] = await db.update(dataSources)
.set(upd)
.where(and(eq(dataSources.id, id), eq(dataSources.tenantId, session.tenantId)))
.returning();
return row;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { DataSourcesController } from './data-sources.controller';
@Module({ controllers: [DataSourcesController] })
export class DataSourcesModule {}

View file

@ -622,3 +622,221 @@ export const researchBriefs = pgTable('research_briefs', {
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ── Financial Intelligence (CC-052) ─────────────────────────────────────────
// Semnale macro/FX/rates/news ingerate din n8n (World Bank, IMF, Eurostat).
export const financialSignals = pgTable(
'financial_signals',
{
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id'),
/** macro | fx | rates | news | commodity */
category: text('category').notNull().default('macro'),
/** global | eu | ro | de | us */
region: text('region').notNull().default('global'),
/** null = se aplica tuturor nișelor */
niche: text('niche'),
source: text('source').notNull(),
indicatorCode: text('indicator_code'),
title: text('title').notNull(),
/** Rezumat generat de AI per nișă */
summary: text('summary').notNull(),
rawValue: numeric('raw_value', { precision: 18, scale: 4 }),
changePercent: numeric('change_percent', { precision: 10, scale: 4 }),
unit: text('unit'),
/** info | warning | critical */
severity: text('severity').notNull().default('info'),
publishedAt: timestamp('published_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [
index('financial_signals_published_idx').on(t.category, t.publishedAt),
index('financial_signals_tenant_idx').on(t.tenantId, t.publishedAt),
],
);
// ============================================================
// Contacts (CRM)
// ============================================================
export const contacts = pgTable('contacts', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
fullName: text('full_name').notNull(),
email: text('email'),
phone: text('phone'),
role: text('role'),
linkedinUrl: text('linkedin_url'),
notes: text('notes'),
tags: text('tags').array().notNull().default([]),
source: text('source'),
consentStatus: text('consent_status').notNull().default('unknown'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ============================================================
// Risks (Risk Register)
// ============================================================
export const risks = pgTable('risks', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
title: text('title').notNull(),
description: text('description'),
category: text('category'),
probability: text('probability').notNull().default('medium'),
impact: text('impact').notNull().default('medium'),
status: text('status').notNull().default('open'),
mitigation: text('mitigation'),
owner: text('owner'),
decisionId: uuid('decision_id'),
reviewDueAt: timestamp('review_due_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ============================================================
// Projects
// ============================================================
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
name: text('name').notNull(),
description: text('description'),
status: text('status').notNull().default('planning'),
priority: text('priority').notNull().default('medium'),
startDate: text('start_date'),
dueDate: text('due_date'),
budgetMinorUnits: integer('budget_minor_units'),
currency: text('currency').default('RON'),
tags: text('tags').array().notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ============================================================
// Contracts
// ============================================================
export const contracts = pgTable('contracts', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
title: text('title').notNull(),
contractType: text('contract_type').notNull().default('other'),
status: text('status').notNull().default('draft'),
parties: text('parties').array().notNull().default([]),
startDate: text('start_date'),
endDate: text('end_date'),
valueMinorUnits: integer('value_minor_units'),
currency: text('currency').default('RON'),
notes: text('notes'),
tags: text('tags').array().notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ============================================================
// Obligations (Deadlines & Obligations)
// ============================================================
export const obligations = pgTable('obligations', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
title: text('title').notNull(),
description: text('description'),
category: text('category').notNull().default('contractual'),
status: text('status').notNull().default('pending'),
dueDate: text('due_date'),
owner: text('owner'),
contractId: uuid('contract_id'),
evidenceUrl: text('evidence_url'),
riskLevel: text('risk_level').notNull().default('medium'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ============================================================
// Pipeline Deals (Sales Pipeline)
// ============================================================
export const pipelineDeals = pgTable('pipeline_deals', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
contactId: uuid('contact_id'),
title: text('title').notNull(),
stage: text('stage').notNull().default('identified'),
valueMinorUnits: integer('value_minor_units'),
currency: text('currency').default('RON'),
probability: integer('probability').default(50),
expectedCloseDate: text('expected_close_date'),
notes: text('notes'),
tags: text('tags').array().notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ——— CC-066 ———
export const actionPlans = pgTable('action_plans', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
title: text('title').notNull(),
description: text('description'),
status: text('status').default('draft').notNull(),
priority: text('priority').default('medium').notNull(),
owner: text('owner'),
dueDate: date('due_date'),
completedAt: timestamp('completed_at'),
decisionId: uuid('decision_id'),
goalId: uuid('goal_id'),
projectId: uuid('project_id'),
tags: text('tags').array().default([]).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
export const scenarios = pgTable('scenarios', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
tenantId: uuid('tenant_id').notNull(),
decisionId: uuid('decision_id'),
title: text('title').notNull(),
description: text('description'),
probability: text('probability').default('medium').notNull(),
outcome: text('outcome'),
financialImpactMinorUnits: numeric('financial_impact_minor_units', { precision: 15, scale: 0 }),
financialImpactCurrency: text('financial_impact_currency').default('RON').notNull(),
impactDirection: text('impact_direction').default('neutral').notNull(),
status: text('status').default('hypothetical').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ——— CC-068 ———
export const dataSources = pgTable('data_sources', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
tenantId: uuid('tenant_id').notNull(),
name: text('name').notNull(),
sourceType: text('source_type').default('manual').notNull(),
description: text('description'),
connectionInfo: text('connection_info'),
status: text('status').default('active').notNull(),
lastSyncAt: timestamp('last_sync_at'),
recordCount: integer('record_count'),
tags: text('tags').array().default([]).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

View file

@ -1,34 +1,112 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { and, desc, eq, isNull, isNotNull, lte } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateDecisionDto, UpdateDecisionDto } from './dto';
import { DecisionsService } from './decisions.service';
import { db } from '../db/client';
import { decisions } from '../db/schema';
class CreateDecisionDto {
@IsString() context!: string;
@IsOptional() @IsArray() options?: string[];
@IsOptional() @IsArray() assumptions?: string[];
@IsOptional() @IsString() workspaceId?: string;
@IsOptional() @IsString() reviewDueAt?: string;
}
class UpdateDecisionDto {
@IsOptional() @IsString() context?: string;
@IsOptional() @IsArray() options?: string[];
@IsOptional() @IsArray() assumptions?: string[];
@IsOptional() @IsString() selectedOption?: string | null;
@IsOptional() @IsString() outcomeReview?: string;
@IsOptional() @IsArray() evidence?: unknown[];
@IsOptional() @IsString() reviewDueAt?: string | null;
}
@Controller('decisions')
export class DecisionsController {
constructor(private readonly decisionsService: DecisionsService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession) {
return this.decisionsService.list(session);
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '50', 10) || 50), 200);
return db.query.decisions.findMany({
where: eq(decisions.tenantId, session.tenantId),
orderBy: [desc(decisions.createdAt)],
limit,
});
}
@Get('pending-review')
async pendingReview(@CurrentSession() session: AuthenticatedSession) {
const now = new Date();
return db.query.decisions.findMany({
where: and(
eq(decisions.tenantId, session.tenantId),
isNotNull(decisions.reviewDueAt),
lte(decisions.reviewDueAt, now),
isNull(decisions.outcomeReview),
),
orderBy: [desc(decisions.reviewDueAt)],
limit: 20,
});
}
@Get(':id')
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.decisionsService.getById(session, id);
async getOne(
@CurrentSession() session: AuthenticatedSession,
@Param('id') id: string,
) {
return db.query.decisions.findFirst({
where: and(eq(decisions.id, id), eq(decisions.tenantId, session.tenantId)),
});
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateDecisionDto) {
return this.decisionsService.create(session, dto);
async create(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: CreateDecisionDto,
) {
const [created] = await db
.insert(decisions)
.values({
tenantId: session.tenantId,
workspaceId: dto.workspaceId ?? null,
ownerUserId: session.userId,
context: dto.context,
options: dto.options ?? [],
assumptions: dto.assumptions ?? [],
evidence: [],
reviewDueAt: dto.reviewDueAt ? new Date(dto.reviewDueAt) : null,
})
.returning();
return created;
}
@Patch(':id')
update(
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Param('id') id: string,
@Body() dto: UpdateDecisionDto,
) {
return this.decisionsService.update(session, id, dto);
const [updated] = await db
.update(decisions)
.set({
...(dto.context !== undefined ? { context: dto.context } : {}),
...(dto.options !== undefined ? { options: dto.options } : {}),
...(dto.assumptions !== undefined ? { assumptions: dto.assumptions } : {}),
...(dto.selectedOption !== undefined ? { selectedOption: dto.selectedOption } : {}),
...(dto.outcomeReview !== undefined ? { outcomeReview: dto.outcomeReview } : {}),
...(dto.evidence !== undefined ? { evidence: dto.evidence } : {}),
...(dto.reviewDueAt !== undefined
? { reviewDueAt: dto.reviewDueAt ? new Date(dto.reviewDueAt) : null }
: {}),
updatedAt: new Date(),
})
.where(and(eq(decisions.id, id), eq(decisions.tenantId, session.tenantId)))
.returning();
return updated;
}
}

View file

@ -1,11 +1,5 @@
import { Module } from '@nestjs/common';
import { DecisionsController } from './decisions.controller';
import { DecisionsService } from './decisions.service';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [AuditModule],
controllers: [DecisionsController],
providers: [DecisionsService],
})
@Module({ controllers: [DecisionsController] })
export class DecisionsModule {}

View file

@ -0,0 +1,31 @@
import { Controller, Get, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { documents } from '../db/schema';
@Controller('documents')
export class DocumentsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('classification') classification?: string,
@Query('ocrStatus') ocrStatus?: string,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '200', 10) || 200), 500);
const conditions = [
eq(documents.tenantId, session.tenantId),
isNull(documents.deletedAt),
];
if (classification) conditions.push(eq(documents.classification, classification as 'c0' | 'c1' | 'c2' | 'c3' | 'c4'));
if (ocrStatus) conditions.push(eq(documents.ocrStatus, ocrStatus));
return db.query.documents.findMany({
where: and(...conditions),
orderBy: desc(documents.createdAt),
limit,
});
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
@Module({ controllers: [DocumentsController] })
export class DocumentsModule {}

View file

@ -0,0 +1,130 @@
import { Body, Controller, Get, IsString, Post } from '@nestjs/common';
import { eq, isNull, and } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import {
organizations,
tasks,
goals,
decisions,
transactions,
aiRequests,
consentRecords,
notifications,
researchBriefs,
savedSegments,
opportunities,
} from '../db/schema';
class DeletionRequestDto {
@IsString() reason!: string;
}
@Controller('export')
export class ExportController {
@Get('data')
async exportData(@CurrentSession() session: AuthenticatedSession) {
const tid = session.tenantId;
const uid = session.userId;
const [
orgs,
tks,
gls,
decs,
txs,
aiReqs,
consents,
notifs,
briefs,
segs,
opps,
] = await Promise.all([
db.query.organizations.findMany({
where: and(eq(organizations.tenantId, tid), isNull(organizations.deletedAt)),
}),
db.query.tasks.findMany({ where: eq(tasks.tenantId, tid) }),
db.query.goals.findMany({ where: eq(goals.tenantId, tid) }),
db.query.decisions.findMany({ where: eq(decisions.tenantId, tid) }),
db.query.transactions.findMany({ where: eq(transactions.tenantId, tid) }),
db.query.aiRequests.findMany({
where: eq(aiRequests.tenantId, tid),
columns: { contextManifest: false },
}),
db.query.consentRecords.findMany({
where: and(
eq(consentRecords.tenantId, tid),
eq(consentRecords.userId, uid),
),
}),
db.query.notifications.findMany({ where: eq(notifications.tenantId, tid) }),
db.query.researchBriefs.findMany({ where: eq(researchBriefs.tenantId, tid) }),
db.query.savedSegments.findMany({ where: eq(savedSegments.tenantId, tid) }),
db.query.opportunities.findMany({ where: eq(opportunities.tenantId, tid) }),
]);
return {
exportedAt: new Date().toISOString(),
tenantId: tid,
userId: uid,
schema: '1.0',
data: {
organizations: orgs,
tasks: tks,
goals: gls,
decisions: decs,
transactions: txs,
aiRequests: aiReqs,
consentRecords: consents,
notifications: notifs,
researchBriefs: briefs,
savedSegments: segs,
opportunities: opps,
},
counts: {
organizations: orgs.length,
tasks: tks.length,
goals: gls.length,
decisions: decs.length,
transactions: txs.length,
aiRequests: aiReqs.length,
consentRecords: consents.length,
notifications: notifs.length,
researchBriefs: briefs.length,
savedSegments: segs.length,
opportunities: opps.length,
},
};
}
@Post('deletion-request')
async deletionRequest(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: DeletionRequestDto,
) {
// Record deletion request as a consent record with special purpose
const existing = await db.query.consentRecords.findFirst({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
eq(consentRecords.purpose, 'account_deletion_requested'),
isNull(consentRecords.revokedAt),
),
});
if (existing) {
return { submitted: true, alreadyPending: true, submittedAt: existing.grantedAt };
}
const [record] = await db
.insert(consentRecords)
.values({
tenantId: session.tenantId,
userId: session.userId,
purpose: 'account_deletion_requested',
grantedAt: new Date(),
metadata: { reason: dto.reason },
})
.returning();
return { submitted: true, alreadyPending: false, submittedAt: record.grantedAt };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
@Module({ controllers: [ExportController] })
export class ExportModule {}

View file

@ -0,0 +1,57 @@
import { Body, Controller, Post } from '@nestjs/common';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
import { ExternalDataService } from './external-data.service';
class LegislationSearchDto {
@IsString() scenario!: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsNumber() topK?: number;
}
class LegislationAskDto {
@IsString() message!: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsEnum(['general', 'fiscal', 'national']) type?: 'general' | 'fiscal' | 'national';
@IsOptional() @IsString() sessionId?: string;
}
class CapabilityCheckDto {
@IsString() capability!: string;
@IsOptional() @IsString() context?: string;
}
@Controller('external')
export class ExternalDataController {
constructor(private readonly svc: ExternalDataService) {}
/**
* POST /v1/external/legislation/search
* Vector search direct în corpusul legislativ returnează secțiuni text.
* 'scenario': descrie situația completă (ex: "Am o firmă GmbH și vreau să angajez remote")
*/
@Post('legislation/search')
searchLegislation(@Body() dto: LegislationSearchDto) {
return this.svc.searchLegislation(dto.scenario, dto.country, dto.topK);
}
/**
* POST /v1/external/legislation/ask
* Răspuns AI via agent AnythingLLM, rutare automată după country + type.
* country: RO | DE | FR | UK | EU
* type: general | fiscal | national
*/
@Post('legislation/ask')
askLegislation(@Body() dto: LegislationAskDto) {
return this.svc.askLegislationAgent(
dto.message,
dto.country ?? 'RO',
dto.type ?? 'general',
dto.sessionId,
);
}
@Post('capability/check')
checkCapability(@Body() dto: CapabilityCheckDto) {
return this.svc.checkCapability(dto.capability, dto.context);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExternalDataController } from './external-data.controller';
import { ExternalDataService } from './external-data.service';
@Module({
controllers: [ExternalDataController],
providers: [ExternalDataService],
exports: [ExternalDataService],
})
export class ExternalDataModule {}

View file

@ -0,0 +1,106 @@
import { BadGatewayException, Injectable } from '@nestjs/common';
const TIMEOUT_MS = 15_000;
async function jsonFetch<T>(url: string, opts: RequestInit = {}): Promise<T> {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers },
signal: ctrl.signal,
});
clearTimeout(id);
if (!res.ok) throw new BadGatewayException(`upstream returned ${res.status}`);
return (await res.json()) as T;
} catch (err) {
clearTimeout(id);
if (err instanceof BadGatewayException) throw err;
throw new BadGatewayException('upstream service unreachable');
}
}
/**
* Mapare țară workspace AnythingLLM.
* Fiecare workspace conține corpusul legal/fiscal al jurisdicției respective.
*/
const COUNTRY_WORKSPACE: Record<string, string> = {
RO: 'agent-juridic-romania',
DE: 'national-germania',
'DE-fiscal': 'fiscal-germania',
'RO-fiscal': 'fiscal-romania',
FR: 'national-franta',
'FR-fiscal': 'fiscal-franta',
UK: 'national-uk',
'UK-fiscal': 'fiscal-uk',
EU: 'agent-juridic-romania', // fallback general
};
@Injectable()
export class ExternalDataService {
/** ceo-os-legislation-api: RAG vector search direct în corpus legislativ */
private readonly legislationUrl =
process.env.LEGISLATION_API_URL ?? 'http://91.98.39.120:9404';
private readonly anythingLlmUrl =
process.env.ANYTHINGLLM_URL ??
'http://anythingllm-n3n3xi75sj0xkh3oey17n962.91.98.39.120.sslip.io';
private readonly anythingLlmKey =
process.env.ANYTHINGLLM_API_KEY ?? '';
/**
* Vector search în corpusul legislativ returnează secțiuni text brut.
* 'scenario' trebuie fie o descriere completă a situației (nu keyword).
*/
async searchLegislation(scenario: string, country?: string, topK = 8) {
return jsonFetch(`${this.legislationUrl}/search_legislation`, {
method: 'POST',
body: JSON.stringify({ scenario, country: country ?? null, top_k: topK }),
});
}
/**
* Răspuns AI interpretat via agent AnythingLLM.
* Rutare automată pe workspace-ul potrivit după țară și tip.
*/
async askLegislationAgent(
message: string,
country = 'RO',
type: 'general' | 'fiscal' | 'national' = 'general',
sessionId?: string,
) {
if (!this.anythingLlmKey) {
throw new BadGatewayException('ANYTHINGLLM_API_KEY not configured');
}
const key = type === 'fiscal' ? `${country}-fiscal` : country;
const workspace =
COUNTRY_WORKSPACE[key] ??
COUNTRY_WORKSPACE[country] ??
'agent-juridic-romania';
return jsonFetch(
`${this.anythingLlmUrl}/api/v1/workspace/${workspace}/chat`,
{
method: 'POST',
headers: { Authorization: `Bearer ${this.anythingLlmKey}` },
body: JSON.stringify({
message,
mode: 'chat',
sessionId: sessionId ?? `ceo-os-${country}-${Date.now()}`,
attachments: [],
}),
},
);
}
async checkCapability(capability: string, context?: string) {
const capUrl = process.env.CAPABILITY_API_URL ?? 'http://91.98.39.120:9402';
return jsonFetch(`${capUrl}/check_capability`, {
method: 'POST',
body: JSON.stringify({ capability, context }),
});
}
}

View file

@ -0,0 +1,24 @@
import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FinancialSignalItemDto {
@IsString() source!: string;
@IsString() title!: string;
@IsString() summary!: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() region?: string;
@IsOptional() @IsString() niche?: string;
@IsOptional() @IsString() indicatorCode?: string;
@IsOptional() @IsNumber() rawValue?: number;
@IsOptional() @IsNumber() changePercent?: number;
@IsOptional() @IsString() unit?: string;
@IsOptional() @IsString() severity?: string;
@IsOptional() @IsString() publishedAt?: string;
@IsOptional() @IsString() expiresAt?: string;
}
export class IngestSignalsDto {
@IsOptional() @IsString() tenantId?: string;
@IsArray() @ValidateNested({ each: true }) @Type(() => FinancialSignalItemDto)
signals!: FinancialSignalItemDto[];
}

View file

@ -0,0 +1,70 @@
import { Body, Controller, Get, Headers, Post, Query, UnauthorizedException } from '@nestjs/common';
import { desc, isNull, or, eq, and, gte } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { Public } from '../auth/public.decorator';
import { db } from '../db/client';
import { financialSignals } from '../db/schema';
import { IngestSignalsDto } from './dto';
@Controller('financial-intelligence')
export class FinancialIntelligenceController {
@Get('signals')
async signals(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
@Query('category') category?: string,
@Query('region') region?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '50', 10) || 50), 200);
const now = new Date();
const conditions = [
or(isNull(financialSignals.tenantId), eq(financialSignals.tenantId, session.tenantId)),
or(isNull(financialSignals.expiresAt), gte(financialSignals.expiresAt, now)),
];
if (category) conditions.push(eq(financialSignals.category, category));
if (region) conditions.push(eq(financialSignals.region, region));
return db.query.financialSignals.findMany({
where: and(...conditions),
orderBy: [desc(financialSignals.publishedAt)],
limit,
});
}
/** POST /v1/financial-intelligence/ingest — n8n webhook, semnat cu X-Ingest-Key */
@Public()
@Post('ingest')
async ingest(
@Headers('x-ingest-key') ingestKey: string | undefined,
@Body() dto: IngestSignalsDto,
) {
const expected = process.env.INGEST_SECRET;
if (expected && ingestKey !== expected) {
throw new UnauthorizedException('Invalid ingest key');
}
if (!dto.signals || dto.signals.length === 0) return { ingested: 0 };
const rows = dto.signals.map((s) => ({
tenantId: dto.tenantId ?? null,
category: s.category ?? 'macro',
region: s.region ?? 'global',
niche: s.niche ?? null,
source: s.source,
indicatorCode: s.indicatorCode ?? null,
title: s.title,
summary: s.summary,
rawValue: s.rawValue != null ? String(s.rawValue) : null,
changePercent: s.changePercent != null ? String(s.changePercent) : null,
unit: s.unit ?? null,
severity: s.severity ?? 'info',
publishedAt: s.publishedAt ? new Date(s.publishedAt) : new Date(),
expiresAt: s.expiresAt ? new Date(s.expiresAt) : null,
}));
await db.insert(financialSignals).values(rows);
return { ingested: rows.length };
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { FinancialIntelligenceController } from './financial-intelligence.controller';
@Module({
controllers: [FinancialIntelligenceController],
})
export class FinancialIntelligenceModule {}

View file

@ -1,42 +1,84 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { IsOptional, IsString } from 'class-validator';
import { and, desc, eq, isNull, or, inArray } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateGoalDto, GOAL_STATUSES, UpdateGoalDto, type GoalStatusValue } from './dto';
import { GoalsService } from './goals.service';
import { db } from '../db/client';
import { goals } from '../db/schema';
class CreateGoalDto {
@IsString() horizon!: string;
@IsString() metric!: string;
@IsString() target!: string;
@IsOptional() @IsString() workspaceId?: string;
}
class UpdateGoalDto {
@IsOptional() @IsString() status?: string;
@IsOptional() milestones?: unknown[];
@IsOptional() @IsString() target?: string;
}
@Controller('goals')
export class GoalsController {
constructor(private readonly goalsService: GoalsService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
if (status && !GOAL_STATUSES.includes(status as GoalStatusValue)) {
throw new BadRequestException(`status must be one of: ${GOAL_STATUSES.join(', ')}`);
}
return this.goalsService.list(session, status as GoalStatusValue | undefined);
async list(@CurrentSession() session: AuthenticatedSession) {
return db.query.goals.findMany({
where: and(eq(goals.tenantId, session.tenantId), isNull(goals.deletedAt)),
orderBy: [desc(goals.createdAt)],
limit: 100,
});
}
@Get(':id')
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.goalsService.getById(session, id);
@Get('at-risk')
async atRisk(@CurrentSession() session: AuthenticatedSession) {
return db.query.goals.findMany({
where: and(
eq(goals.tenantId, session.tenantId),
isNull(goals.deletedAt),
inArray(goals.status, ['at_risk', 'not_started']),
),
orderBy: [desc(goals.createdAt)],
limit: 20,
});
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateGoalDto) {
return this.goalsService.create(session, dto);
async create(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: CreateGoalDto,
) {
const [created] = await db
.insert(goals)
.values({
tenantId: session.tenantId,
workspaceId: dto.workspaceId ?? null,
ownerUserId: session.userId,
horizon: dto.horizon,
metric: dto.metric,
target: dto.target,
})
.returning();
return created;
}
@Patch(':id')
update(
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Param('id') id: string,
@Body() dto: UpdateGoalDto,
) {
return this.goalsService.update(session, id, dto);
}
@Delete(':id')
softDelete(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.goalsService.softDelete(session, id);
const [updated] = await db
.update(goals)
.set({
...(dto.status !== undefined ? { status: dto.status as any } : {}),
...(dto.milestones !== undefined ? { milestones: dto.milestones } : {}),
...(dto.target !== undefined ? { target: dto.target } : {}),
updatedAt: new Date(),
version: db.$count(goals, eq(goals.id, id)),
})
.where(and(eq(goals.id, id), eq(goals.tenantId, session.tenantId)))
.returning();
return updated;
}
}

View file

@ -1,12 +1,5 @@
import { Module } from '@nestjs/common';
import { GoalsController } from './goals.controller';
import { GoalsService } from './goals.service';
import { EventsModule } from '../events/events.module';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [EventsModule, AuditModule],
controllers: [GoalsController],
providers: [GoalsService],
})
@Module({ controllers: [GoalsController] })
export class GoalsModule {}

View file

@ -4,7 +4,9 @@ import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { AppModule } from './app.module';
import { db } from './db/client';
const REQUIRED_ENV_VARS = [
'DATABASE_URL',
@ -26,6 +28,18 @@ function validateEnv(): void {
async function bootstrap() {
validateEnv();
// Apply pending DB migrations — skip if schema was pre-initialized via drizzle push
try {
await migrate(db, { migrationsFolder: './drizzle' });
} catch (err) {
const msg = (err as Error & { message: string }).message ?? '';
if (msg.includes('already exists')) {
console.warn('[bootstrap] Schema already initialized — migration skipped. Run db:migrate manually if needed.');
} else {
throw err;
}
}
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
app.use(helmet());

View file

@ -0,0 +1,51 @@
import { Body, Controller, Get, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { obligations } from '../db/schema';
class CreateObligationDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() contractId?: string;
@IsOptional() @IsString() riskLevel?: string;
}
class UpdateObligationDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() evidenceUrl?: string;
@IsOptional() @IsString() riskLevel?: string;
}
@Controller('obligations')
export class ObligationsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(obligations.tenantId, session.tenantId), eq(obligations.status, status))
: eq(obligations.tenantId, session.tenantId);
return db.query.obligations.findMany({ where, orderBy: desc(obligations.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateObligationDto) {
const [obligation] = await db.insert(obligations).values({ ...dto, tenantId: session.tenantId }).returning();
return obligation;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateObligationDto) {
const [updated] = await db.update(obligations).set({ ...dto, updatedAt: new Date() })
.where(and(eq(obligations.id, id), eq(obligations.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Obligation not found');
return updated;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ObligationsController } from './obligations.controller';
@Module({ controllers: [ObligationsController] })
export class ObligationsModule {}

View file

@ -0,0 +1,80 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { outcomeReviews } from '../db/schema';
class CreateOutcomeReviewDto {
@IsString() title!: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsString() periodStart?: string;
@IsOptional() @IsString() periodEnd?: string;
@IsOptional() @IsString() actualOutcome?: string;
@IsOptional() @IsBoolean() @Type(() => Boolean) wasSuccessful?: boolean;
@IsOptional() @IsString() lessonsLearned?: string;
@IsOptional() @IsString() nextSteps?: string;
@IsOptional() @IsInt() @Min(1) @Max(10) @Type(() => Number) rating?: number;
}
class UpdateOutcomeReviewDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() actualOutcome?: string;
@IsOptional() @IsBoolean() @Type(() => Boolean) wasSuccessful?: boolean;
@IsOptional() @IsString() lessonsLearned?: string;
@IsOptional() @IsString() nextSteps?: string;
@IsOptional() @IsInt() @Min(1) @Max(10) @Type(() => Number) rating?: number;
}
@Controller('outcome-reviews')
export class OutcomeReviewsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('decisionId') decisionId?: string,
) {
const tid = session.tenantId;
let where = eq(outcomeReviews.tenantId, tid) as ReturnType<typeof eq>;
if (decisionId) where = and(where, eq(outcomeReviews.decisionId, decisionId)) as typeof where;
return db.query.outcomeReviews.findMany({ where, orderBy: [desc(outcomeReviews.createdAt)], limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateOutcomeReviewDto) {
const [row] = await db.insert(outcomeReviews).values({
tenantId: session.tenantId,
title: dto.title,
decisionId: dto.decisionId ?? null,
periodStart: dto.periodStart ?? null,
periodEnd: dto.periodEnd ?? null,
actualOutcome: dto.actualOutcome,
wasSuccessful: dto.wasSuccessful ?? null,
lessonsLearned: dto.lessonsLearned,
nextSteps: dto.nextSteps,
rating: dto.rating ?? null,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateOutcomeReviewDto,
) {
const upd: Record<string, unknown> = { updatedAt: new Date() };
if (dto.title !== undefined) upd.title = dto.title;
if (dto.actualOutcome !== undefined) upd.actualOutcome = dto.actualOutcome;
if (dto.wasSuccessful !== undefined) upd.wasSuccessful = dto.wasSuccessful;
if (dto.lessonsLearned !== undefined) upd.lessonsLearned = dto.lessonsLearned;
if (dto.nextSteps !== undefined) upd.nextSteps = dto.nextSteps;
if (dto.rating !== undefined) upd.rating = dto.rating;
const [row] = await db.update(outcomeReviews)
.set(upd)
.where(and(eq(outcomeReviews.id, id), eq(outcomeReviews.tenantId, session.tenantId)))
.returning();
return row;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { OutcomeReviewsController } from './outcome-reviews.controller';
@Module({ controllers: [OutcomeReviewsController] })
export class OutcomeReviewsModule {}

View file

@ -0,0 +1,56 @@
import { Body, Controller, Get, IsArray, IsNumber, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { Type } from 'class-transformer';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { pipelineDeals } from '../db/schema';
class CreateDealDto {
@IsString() title!: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() contactId?: string;
@IsOptional() @IsString() stage?: string;
@IsOptional() @IsNumber() @Type(() => Number) valueMinorUnits?: number;
@IsOptional() @IsString() currency?: string;
@IsOptional() @IsNumber() @Type(() => Number) probability?: number;
@IsOptional() @IsString() expectedCloseDate?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
}
class UpdateDealDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() stage?: string;
@IsOptional() @IsNumber() @Type(() => Number) valueMinorUnits?: number;
@IsOptional() @IsNumber() @Type(() => Number) probability?: number;
@IsOptional() @IsString() expectedCloseDate?: string;
@IsOptional() @IsString() notes?: string;
}
@Controller('pipeline')
export class PipelineController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('stage') stage?: string) {
const where = stage
? and(eq(pipelineDeals.tenantId, session.tenantId), isNull(pipelineDeals.deletedAt), eq(pipelineDeals.stage, stage))
: and(eq(pipelineDeals.tenantId, session.tenantId), isNull(pipelineDeals.deletedAt));
return db.query.pipelineDeals.findMany({ where, orderBy: desc(pipelineDeals.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateDealDto) {
const [deal] = await db.insert(pipelineDeals).values({
...dto, tenantId: session.tenantId, tags: dto.tags ?? [],
}).returning();
return deal;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDealDto) {
const [updated] = await db.update(pipelineDeals).set({ ...dto, updatedAt: new Date() })
.where(and(eq(pipelineDeals.id, id), eq(pipelineDeals.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Deal not found');
return updated;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { PipelineController } from './pipeline.controller';
@Module({ controllers: [PipelineController] })
export class PipelineModule {}

View file

@ -0,0 +1,66 @@
import { Body, Controller, Get, IsArray, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { projects } from '../db/schema';
class CreateProjectDto {
@IsString() name!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() priority?: string;
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsString() currency?: string;
@IsOptional() @IsArray() tags?: string[];
}
class UpdateProjectDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() priority?: string;
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsArray() tags?: string[];
}
@Controller('projects')
export class ProjectsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(projects.tenantId, session.tenantId), isNull(projects.deletedAt), eq(projects.status, status))
: and(eq(projects.tenantId, session.tenantId), isNull(projects.deletedAt));
return db.query.projects.findMany({ where, orderBy: desc(projects.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateProjectDto) {
const [project] = await db.insert(projects).values({
...dto,
tenantId: session.tenantId,
tags: dto.tags ?? [],
}).returning();
return project;
}
@Get(':id')
async getOne(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, id), eq(projects.tenantId, session.tenantId), isNull(projects.deletedAt)),
});
if (!project) throw new NotFoundException('Project not found');
return project;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateProjectDto) {
const [updated] = await db.update(projects).set({ ...dto, updatedAt: new Date() })
.where(and(eq(projects.id, id), eq(projects.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Project not found');
return updated;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller';
@Module({ controllers: [ProjectsController] })
export class ProjectsModule {}

View file

@ -0,0 +1,68 @@
import { Body, Controller, Get, HttpCode, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { risks } from '../db/schema';
class CreateRiskDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() probability?: string;
@IsOptional() @IsString() impact?: string;
@IsOptional() @IsString() mitigation?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() decisionId?: string;
@IsOptional() @IsString() reviewDueAt?: string;
}
class UpdateRiskDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() probability?: string;
@IsOptional() @IsString() impact?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() mitigation?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() reviewDueAt?: string;
}
@Controller('risks')
export class RisksController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(risks.tenantId, session.tenantId), eq(risks.status, status))
: eq(risks.tenantId, session.tenantId);
return db.query.risks.findMany({ where, orderBy: desc(risks.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateRiskDto) {
const [risk] = await db.insert(risks).values({
...dto,
tenantId: session.tenantId,
reviewDueAt: dto.reviewDueAt ? new Date(dto.reviewDueAt) : undefined,
}).returning();
return risk;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRiskDto) {
const [updated] = await db.update(risks).set({
...dto,
updatedAt: new Date(),
reviewDueAt: dto.reviewDueAt !== undefined ? (dto.reviewDueAt ? new Date(dto.reviewDueAt) : null) : undefined,
}).where(and(eq(risks.id, id), eq(risks.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Risk not found');
return updated;
}
@HttpCode(204) @Post(':id/close')
async close(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(risks).set({ status: 'closed', updatedAt: new Date() })
.where(and(eq(risks.id, id), eq(risks.tenantId, session.tenantId)));
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { RisksController } from './risks.controller';
@Module({ controllers: [RisksController] })
export class RisksModule {}

View file

@ -0,0 +1,90 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { scenarios } from '../db/schema';
class CreateScenarioDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsIn(['low','medium','high']) probability?: string;
@IsOptional() @IsString() outcome?: string;
@IsOptional() @IsNumberString() financialImpactMinorUnits?: string;
@IsOptional() @IsString() financialImpactCurrency?: string;
@IsOptional() @IsIn(['positive','negative','neutral']) impactDirection?: string;
@IsOptional() @IsIn(['hypothetical','likely','confirmed','ruled_out']) status?: string;
}
class UpdateScenarioDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['low','medium','high']) probability?: string;
@IsOptional() @IsString() outcome?: string;
@IsOptional() @IsNumberString() financialImpactMinorUnits?: string;
@IsOptional() @IsIn(['positive','negative','neutral']) impactDirection?: string;
@IsOptional() @IsIn(['hypothetical','likely','confirmed','ruled_out']) status?: string;
}
@Controller('scenarios')
export class ScenariosController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('decisionId') decisionId?: string,
@Query('status') status?: string,
) {
const tid = session.tenantId;
let where = eq(scenarios.tenantId, tid) as ReturnType<typeof eq>;
if (decisionId) where = and(where, eq(scenarios.decisionId, decisionId)) as typeof where;
if (status) where = and(where, eq(scenarios.status, status)) as typeof where;
return db.query.scenarios.findMany({ where, orderBy: [desc(scenarios.createdAt)], limit: 500 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateScenarioDto) {
const [row] = await db.insert(scenarios).values({
tenantId: session.tenantId,
title: dto.title,
description: dto.description,
decisionId: dto.decisionId ?? null,
probability: dto.probability ?? 'medium',
outcome: dto.outcome,
financialImpactMinorUnits: dto.financialImpactMinorUnits ?? null,
financialImpactCurrency: dto.financialImpactCurrency ?? 'RON',
impactDirection: dto.impactDirection ?? 'neutral',
status: dto.status ?? 'hypothetical',
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScenarioDto,
) {
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (dto.title !== undefined) updates.title = dto.title;
if (dto.description !== undefined) updates.description = dto.description;
if (dto.probability !== undefined) updates.probability = dto.probability;
if (dto.outcome !== undefined) updates.outcome = dto.outcome;
if (dto.financialImpactMinorUnits !== undefined) updates.financialImpactMinorUnits = dto.financialImpactMinorUnits;
if (dto.impactDirection !== undefined) updates.impactDirection = dto.impactDirection;
if (dto.status !== undefined) updates.status = dto.status;
const [row] = await db.update(scenarios)
.set(updates)
.where(and(eq(scenarios.id, id), eq(scenarios.tenantId, session.tenantId)))
.returning();
return row;
}
@Delete(':id')
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.delete(scenarios)
.where(and(eq(scenarios.id, id), eq(scenarios.tenantId, session.tenantId)));
return { deleted: true };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ScenariosController } from './scenarios.controller';
@Module({ controllers: [ScenariosController] })
export class ScenariosModule {}

View file

@ -0,0 +1,136 @@
import { Controller, Get, Query } from '@nestjs/common';
import { and, desc, eq, ilike, isNull, or } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import {
organizations, tasks, goals, decisions, researchBriefs, savedSegments,
contacts, risks, projects, contracts, obligations, pipelineDeals,
} from '../db/schema';
interface SearchItem { type: string; id: string; title: string; subtitle?: string; route: string; }
interface SearchGroup { type: string; label: string; items: SearchItem[]; }
@Controller('search')
export class SearchController {
@Get()
async search(
@CurrentSession() session: AuthenticatedSession,
@Query('q') q = '',
) {
const term = q.trim();
if (!term || term.length < 2) return { q: term, groups: [] };
const tid = session.tenantId;
const pat = `%${term}%`;
const B = '/dashboard';
const [
orgs, tks, gls, decs, briefs, segs,
ctcs, rsks, projs, ctrcts, obligs, deals,
] = await Promise.all([
db.query.organizations.findMany({
where: and(eq(organizations.tenantId, tid), isNull(organizations.deletedAt), or(ilike(organizations.name, pat), ilike(organizations.domain, pat))),
columns: { id: true, name: true, country: true, domain: true }, limit: 5,
}),
db.query.tasks.findMany({
where: and(eq(tasks.tenantId, tid), ilike(tasks.title, pat)),
columns: { id: true, title: true, status: true }, limit: 5,
}),
db.query.goals.findMany({
where: and(eq(goals.tenantId, tid), or(ilike(goals.metric, pat), ilike(goals.horizon, pat))),
columns: { id: true, metric: true, target: true, horizon: true, status: true }, limit: 5,
}),
db.query.decisions.findMany({
where: and(eq(decisions.tenantId, tid), ilike(decisions.context, pat)),
columns: { id: true, context: true, selectedOption: true }, limit: 5,
}),
db.query.researchBriefs.findMany({
where: and(eq(researchBriefs.tenantId, tid), or(ilike(researchBriefs.title, pat), ilike(researchBriefs.organizationName, pat))),
columns: { id: true, title: true, organizationName: true }, limit: 4,
}),
db.query.savedSegments.findMany({
where: and(eq(savedSegments.tenantId, tid), ilike(savedSegments.name, pat)),
columns: { id: true, name: true }, limit: 4,
}),
db.query.contacts.findMany({
where: and(eq(contacts.tenantId, tid), isNull(contacts.deletedAt), or(ilike(contacts.fullName, pat), ilike(contacts.email, pat), ilike(contacts.role, pat))),
columns: { id: true, fullName: true, email: true, role: true }, limit: 5,
}),
db.query.risks.findMany({
where: and(eq(risks.tenantId, tid), or(ilike(risks.title, pat), ilike(risks.category, pat))),
columns: { id: true, title: true, status: true, probability: true, impact: true }, limit: 5,
}),
db.query.projects.findMany({
where: and(eq(projects.tenantId, tid), isNull(projects.deletedAt), or(ilike(projects.name, pat), ilike(projects.description, pat))),
columns: { id: true, name: true, status: true, priority: true }, limit: 5,
}),
db.query.contracts.findMany({
where: and(eq(contracts.tenantId, tid), isNull(contracts.deletedAt), ilike(contracts.title, pat)),
columns: { id: true, title: true, contractType: true, status: true }, limit: 4,
}),
db.query.obligations.findMany({
where: and(eq(obligations.tenantId, tid), ilike(obligations.title, pat)),
columns: { id: true, title: true, status: true, category: true }, limit: 4,
}),
db.query.pipelineDeals.findMany({
where: and(eq(pipelineDeals.tenantId, tid), isNull(pipelineDeals.deletedAt), ilike(pipelineDeals.title, pat)),
columns: { id: true, title: true, stage: true }, limit: 4,
}),
]);
const groups: SearchGroup[] = [
{
type: 'organization', label: 'Organizații',
items: orgs.map((o) => ({ type: 'organization', id: o.id, title: o.name, subtitle: o.country ?? o.domain ?? undefined, route: `${B}/organizations` })),
},
{
type: 'task', label: 'Taskuri',
items: tks.map((t) => ({ type: 'task', id: t.id, title: t.title, subtitle: t.status, route: `${B}/tasks` })),
},
{
type: 'goal', label: 'Obiective',
items: gls.map((g) => ({ type: 'goal', id: g.id, title: g.metric, subtitle: `${g.target} · ${g.horizon}`, route: `${B}/goals` })),
},
{
type: 'decision', label: 'Decizii',
items: decs.map((d) => ({ type: 'decision', id: d.id, title: (d.context ?? '').slice(0, 60), subtitle: d.selectedOption ?? 'În analiză', route: `${B}/decisions/workspace` })),
},
{
type: 'contact', label: 'Contacte',
items: ctcs.map((c) => ({ type: 'contact', id: c.id, title: c.fullName, subtitle: c.role ?? c.email ?? undefined, route: `${B}/crm` })),
},
{
type: 'project', label: 'Proiecte',
items: projs.map((p) => ({ type: 'project', id: p.id, title: p.name, subtitle: `${p.status} · ${p.priority}`, route: `${B}/projects` })),
},
{
type: 'risk', label: 'Riscuri',
items: rsks.map((r) => ({ type: 'risk', id: r.id, title: r.title, subtitle: `${r.probability}/${r.impact} · ${r.status}`, route: `${B}/risks` })),
},
{
type: 'contract', label: 'Contracte',
items: ctrcts.map((c) => ({ type: 'contract', id: c.id, title: c.title, subtitle: `${c.contractType} · ${c.status}`, route: `${B}/contracts` })),
},
{
type: 'obligation', label: 'Obligații',
items: obligs.map((o) => ({ type: 'obligation', id: o.id, title: o.title, subtitle: o.category, route: `${B}/obligations` })),
},
{
type: 'deal', label: 'Pipeline',
items: deals.map((d) => ({ type: 'deal', id: d.id, title: d.title, subtitle: d.stage, route: `${B}/pipeline` })),
},
{
type: 'research', label: 'Research',
items: briefs.map((b) => ({ type: 'research', id: b.id, title: b.title, subtitle: b.organizationName ?? undefined, route: `${B}/research` })),
},
{
type: 'segment', label: 'Segmente',
items: segs.map((s) => ({ type: 'segment', id: s.id, title: s.name, route: `${B}/segments` })),
},
].filter((g) => g.items.length > 0);
const totalCount = groups.reduce((s, g) => s + g.items.length, 0);
return { q: term, totalCount, groups };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
@Module({ controllers: [SearchController] })
export class SearchModule {}

View file

@ -1,43 +1,106 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { and, asc, desc, eq, isNull, inArray, lt, gte } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateTaskDto, TASK_STATUSES, UpdateTaskDto, type TaskStatusValue } from './dto';
import { TasksService } from './tasks.service';
import { BadRequestException } from '@nestjs/common';
import { db } from '../db/client';
import { tasks } from '../db/schema';
class CreateTaskDto {
@IsString() title!: string;
@IsOptional() @IsNumber() priority?: number;
@IsOptional() @IsString() dueAt?: string;
@IsOptional() @IsString() workspaceId?: string;
@IsOptional() @IsString() sourceEntityType?: string;
@IsOptional() @IsString() sourceEntityId?: string;
}
class UpdateTaskDto {
@IsOptional() @IsString() status?: string;
@IsOptional() @IsNumber() priority?: number;
@IsOptional() @IsString() dueAt?: string;
@IsOptional() @IsString() title?: string;
}
const ACTIVE_STATUSES = ['open', 'in_progress', 'blocked'] as const;
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
if (status && !TASK_STATUSES.includes(status as TaskStatusValue)) {
throw new BadRequestException(`status must be one of: ${TASK_STATUSES.join(', ')}`);
}
return this.tasksService.list(session, status as TaskStatusValue | undefined);
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('status') status?: string,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '100', 10) || 100), 500);
const statuses = status
? [status]
: [...ACTIVE_STATUSES];
return db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, session.tenantId),
isNull(tasks.deletedAt),
inArray(tasks.status, statuses as any[]),
),
orderBy: [asc(tasks.priority), asc(tasks.dueAt), desc(tasks.createdAt)],
limit,
});
}
@Get(':id')
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.getById(session, id);
@Get('overdue')
async overdue(@CurrentSession() session: AuthenticatedSession) {
const now = new Date();
return db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, session.tenantId),
isNull(tasks.deletedAt),
inArray(tasks.status, [...ACTIVE_STATUSES] as any[]),
lt(tasks.dueAt, now),
),
orderBy: [asc(tasks.dueAt)],
limit: 50,
});
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateTaskDto) {
return this.tasksService.create(session, dto);
async create(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: CreateTaskDto,
) {
const [created] = await db
.insert(tasks)
.values({
tenantId: session.tenantId,
workspaceId: dto.workspaceId ?? null,
ownerUserId: session.userId,
title: dto.title,
priority: dto.priority ?? 3,
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
sourceEntityType: dto.sourceEntityType ?? null,
sourceEntityId: dto.sourceEntityId ? dto.sourceEntityId as any : null,
})
.returning();
return created;
}
@Patch(':id')
update(
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Param('id') id: string,
@Body() dto: UpdateTaskDto,
) {
return this.tasksService.update(session, id, dto);
}
@Delete(':id')
softDelete(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.softDelete(session, id);
const [updated] = await db
.update(tasks)
.set({
...(dto.status !== undefined ? { status: dto.status as any } : {}),
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
...(dto.dueAt !== undefined ? { dueAt: dto.dueAt ? new Date(dto.dueAt) : null } : {}),
...(dto.title !== undefined ? { title: dto.title } : {}),
updatedAt: new Date(),
})
.where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId)))
.returning();
return updated;
}
}

View file

@ -1,11 +1,5 @@
import { Module } from '@nestjs/common';
import { EventsModule } from '../events/events.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [EventsModule],
controllers: [TasksController],
providers: [TasksService],
})
@Module({ controllers: [TasksController] })
export class TasksModule {}