Compare commits
92 commits
cc-047-com
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9844f0bd2c | |||
| 03ec11dca5 | |||
| 73976a8a7f | |||
| 5dbe2112a9 | |||
| 9cd14e34bb | |||
| fac78b309f | |||
| 8c0d366300 | |||
| 71b2ed43ee | |||
| c2a070b33f | |||
| a171888ae3 | |||
| f80f8e6c48 | |||
| cbeb3b35a9 | |||
| 6251954890 | |||
| 5de297c848 | |||
| e25a83e557 | |||
| 988a7db958 | |||
| a8d8e4fc32 | |||
| 9a67e49605 | |||
| f3278d8264 | |||
| f1cbe941e9 | |||
| 641c68ed91 | |||
| 972a0f6f9d | |||
| e368f05ca4 | |||
| 71d06cebfb | |||
| bc4ee37359 | |||
| d48bc25b46 | |||
| 1758150ceb | |||
| 9953cce2d4 | |||
| 76cf59d82e | |||
| c3ecab8259 | |||
| 035b71108b | |||
| e2756ffe79 | |||
| b4417ab103 | |||
| 0488e0f426 | |||
| 9f40a1b9b8 | |||
| f1ac86ea9c | |||
| de947d343f | |||
| 867125c60a | |||
| 752f3c0326 | |||
| 2928271b44 | |||
| da8ac3359a | |||
| 080769998a | |||
| 1fc75369c0 | |||
| 902a0d0748 | |||
| 47b85203bd | |||
| c5d256ea78 | |||
| fe1bdbbbe0 | |||
| 1358b5d4dd | |||
| eca3f4a48b | |||
| 8f69f9d95c | |||
| 9d58aa90be | |||
| 18e427bde6 | |||
| 497aaa919b | |||
| c8a517cd2d | |||
| 0a5ae5d483 | |||
| 67cfd7cb12 | |||
| ff68bb2ca5 | |||
| 2cf220133f | |||
| 54a0c50bbc | |||
| 80eb62bca7 | |||
| 7240fb5805 | |||
| ed6f96fc46 | |||
| dc13a487b5 | |||
| d46b38c216 | |||
| 2e7e0c85ea | |||
| 1570e69b2d | |||
| 925ce11a06 | |||
| f52fe6d709 | |||
| 6316717850 | |||
| aefd370d78 | |||
| 3a39adb4bc | |||
| 7f6599d3f8 | |||
| 835b4a6574 | |||
| 929f04f9a6 | |||
| 93e76d4487 | |||
| ae7b1d63b1 | |||
| b0415c6e76 | |||
| 04f142e7ea | |||
| 871dac9f0a | |||
| 0cd84f18c4 | |||
| 0b2c515fe7 | |||
| ebbbff123f | |||
| b769613e49 | |||
| c923d6a042 | |||
| 2408925264 | |||
| 46a152dd13 | |||
| ae7be88829 | |||
| 53eef93e84 | |||
| a08e43aedb | |||
| ddb39df95f | |||
| dccd0bbffa | |||
| af81d10242 |
59 changed files with 2602 additions and 97 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
26
drizzle/0009_financial_intelligence.sql
Normal file
26
drizzle/0009_financial_intelligence.sql
Normal 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);
|
||||
77
drizzle/0010_ceo_os_features.sql
Normal file
77
drizzle/0010_ceo_os_features.sql
Normal 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
|
||||
);
|
||||
61
drizzle/0011_contracts_pipeline.sql
Normal file
61
drizzle/0011_contracts_pipeline.sql
Normal 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
|
||||
);
|
||||
36
drizzle/0012_action_plans_scenarios.sql
Normal file
36
drizzle/0012_action_plans_scenarios.sql
Normal 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
|
||||
);
|
||||
31
drizzle/0013_assumptions_outcomes.sql
Normal file
31
drizzle/0013_assumptions_outcomes.sql
Normal 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
|
||||
);
|
||||
15
drizzle/0014_data_sources.sql
Normal file
15
drizzle/0014_data_sources.sql
Normal 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
|
||||
);
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
91
src/action-plans/action-plans.controller.ts
Normal file
91
src/action-plans/action-plans.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/action-plans/action-plans.module.ts
Normal file
5
src/action-plans/action-plans.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ActionPlansController } from './action-plans.controller';
|
||||
|
||||
@Module({ controllers: [ActionPlansController] })
|
||||
export class ActionPlansModule {}
|
||||
124
src/ai-requests/ai-requests.controller.ts
Normal file
124
src/ai-requests/ai-requests.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
7
src/ai-requests/ai-requests.module.ts
Normal file
7
src/ai-requests/ai-requests.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AiRequestsController } from './ai-requests.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AiRequestsController],
|
||||
})
|
||||
export class AiRequestsModule {}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
82
src/assumptions/assumptions.controller.ts
Normal file
82
src/assumptions/assumptions.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/assumptions/assumptions.module.ts
Normal file
5
src/assumptions/assumptions.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AssumptionsController } from './assumptions.controller';
|
||||
|
||||
@Module({ controllers: [AssumptionsController] })
|
||||
export class AssumptionsModule {}
|
||||
22
src/audit/audit.controller.ts
Normal file
22
src/audit/audit.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
81
src/consents/consents.controller.ts
Normal file
81
src/consents/consents.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/consents/consents.module.ts
Normal file
5
src/consents/consents.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConsentsController } from './consents.controller';
|
||||
|
||||
@Module({ controllers: [ConsentsController] })
|
||||
export class ConsentsModule {}
|
||||
78
src/contacts/contacts.controller.ts
Normal file
78
src/contacts/contacts.controller.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
5
src/contacts/contacts.module.ts
Normal file
5
src/contacts/contacts.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContactsController } from './contacts.controller';
|
||||
|
||||
@Module({ controllers: [ContactsController] })
|
||||
export class ContactsModule {}
|
||||
70
src/contracts/contracts.controller.ts
Normal file
70
src/contracts/contracts.controller.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
5
src/contracts/contracts.module.ts
Normal file
5
src/contracts/contracts.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContractsController } from './contracts.controller';
|
||||
|
||||
@Module({ controllers: [ContractsController] })
|
||||
export class ContractsModule {}
|
||||
71
src/data-sources/data-sources.controller.ts
Normal file
71
src/data-sources/data-sources.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/data-sources/data-sources.module.ts
Normal file
5
src/data-sources/data-sources.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DataSourcesController } from './data-sources.controller';
|
||||
|
||||
@Module({ controllers: [DataSourcesController] })
|
||||
export class DataSourcesModule {}
|
||||
218
src/db/schema.ts
218
src/db/schema.ts
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
31
src/documents/documents.controller.ts
Normal file
31
src/documents/documents.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
5
src/documents/documents.module.ts
Normal file
5
src/documents/documents.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DocumentsController } from './documents.controller';
|
||||
|
||||
@Module({ controllers: [DocumentsController] })
|
||||
export class DocumentsModule {}
|
||||
130
src/export/export.controller.ts
Normal file
130
src/export/export.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/export/export.module.ts
Normal file
5
src/export/export.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExportController } from './export.controller';
|
||||
|
||||
@Module({ controllers: [ExportController] })
|
||||
export class ExportModule {}
|
||||
57
src/external-data/external-data.controller.ts
Normal file
57
src/external-data/external-data.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
src/external-data/external-data.module.ts
Normal file
10
src/external-data/external-data.module.ts
Normal 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 {}
|
||||
106
src/external-data/external-data.service.ts
Normal file
106
src/external-data/external-data.service.ts
Normal 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 să 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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/financial-intelligence/dto.ts
Normal file
24
src/financial-intelligence/dto.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FinancialIntelligenceController } from './financial-intelligence.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FinancialIntelligenceController],
|
||||
})
|
||||
export class FinancialIntelligenceModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
14
src/main.ts
14
src/main.ts
|
|
@ -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());
|
||||
|
|
|
|||
51
src/obligations/obligations.controller.ts
Normal file
51
src/obligations/obligations.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/obligations/obligations.module.ts
Normal file
5
src/obligations/obligations.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ObligationsController } from './obligations.controller';
|
||||
|
||||
@Module({ controllers: [ObligationsController] })
|
||||
export class ObligationsModule {}
|
||||
80
src/outcome-reviews/outcome-reviews.controller.ts
Normal file
80
src/outcome-reviews/outcome-reviews.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/outcome-reviews/outcome-reviews.module.ts
Normal file
5
src/outcome-reviews/outcome-reviews.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OutcomeReviewsController } from './outcome-reviews.controller';
|
||||
|
||||
@Module({ controllers: [OutcomeReviewsController] })
|
||||
export class OutcomeReviewsModule {}
|
||||
56
src/pipeline/pipeline.controller.ts
Normal file
56
src/pipeline/pipeline.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/pipeline/pipeline.module.ts
Normal file
5
src/pipeline/pipeline.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PipelineController } from './pipeline.controller';
|
||||
|
||||
@Module({ controllers: [PipelineController] })
|
||||
export class PipelineModule {}
|
||||
66
src/projects/projects.controller.ts
Normal file
66
src/projects/projects.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/projects/projects.module.ts
Normal file
5
src/projects/projects.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
|
||||
@Module({ controllers: [ProjectsController] })
|
||||
export class ProjectsModule {}
|
||||
68
src/risks/risks.controller.ts
Normal file
68
src/risks/risks.controller.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
5
src/risks/risks.module.ts
Normal file
5
src/risks/risks.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { RisksController } from './risks.controller';
|
||||
|
||||
@Module({ controllers: [RisksController] })
|
||||
export class RisksModule {}
|
||||
90
src/scenarios/scenarios.controller.ts
Normal file
90
src/scenarios/scenarios.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/scenarios/scenarios.module.ts
Normal file
5
src/scenarios/scenarios.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ScenariosController } from './scenarios.controller';
|
||||
|
||||
@Module({ controllers: [ScenariosController] })
|
||||
export class ScenariosModule {}
|
||||
136
src/search/search.controller.ts
Normal file
136
src/search/search.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/search/search.module.ts
Normal file
5
src/search/search.module.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
|
||||
@Module({ controllers: [SearchController] })
|
||||
export class SearchModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue