feat: Faza A2 -- intelligence proxy, saved segments, research briefs, briefing
- IntelligenceModule: proxy tenant-scoped catre intelligence-api
(/companies/search, /companies/{id}); ceo-web nu vorbeste niciodata
direct cu intelligence-api (blueprint 10.3/16).
- SavedSegments: salveaza o cautare Apollo si o ruleaza din nou oricand.
- ResearchBriefs: dovezi curatate manual per companie, cu sursa Apollo
implicita plus surse suplimentare -- explicit fara rezumat generat de
AI (AI Gateway inca neconstruit, blueprint 14).
- BriefingService: /v1/briefing/today, agregare deterministica de
taskuri restante/viitoare + activitate saptamanala; campul
aiExplanation ramane null si vizibil in raspuns, nu simulat.
This commit is contained in:
parent
0ea8e69233
commit
0f8390f31b
21 changed files with 1882 additions and 0 deletions
22
drizzle/0002_burly_nightcrawler.sql
Normal file
22
drizzle/0002_burly_nightcrawler.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "research_briefs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"created_by_user_id" uuid NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"organization_name" text,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"summary" text NOT NULL,
|
||||||
|
"sources" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "saved_segments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"created_by_user_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"query" text NOT NULL,
|
||||||
|
"result_limit" integer DEFAULT 20 NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
1330
drizzle/meta/0002_snapshot.json
Normal file
1330
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,13 @@
|
||||||
"when": 1785264962186,
|
"when": 1785264962186,
|
||||||
"tag": "0001_lovely_brood",
|
"tag": "0001_lovely_brood",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785268609839,
|
||||||
|
"tag": "0002_burly_nightcrawler",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -13,6 +13,10 @@ import { AuditModule } from './audit/audit.module';
|
||||||
import { OrganizationsModule } from './organizations/organizations.module';
|
import { OrganizationsModule } from './organizations/organizations.module';
|
||||||
import { TasksModule } from './tasks/tasks.module';
|
import { TasksModule } from './tasks/tasks.module';
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
|
import { IntelligenceModule } from './intelligence/intelligence.module';
|
||||||
|
import { SegmentsModule } from './segments/segments.module';
|
||||||
|
import { ResearchBriefsModule } from './research-briefs/research-briefs.module';
|
||||||
|
import { BriefingModule } from './briefing/briefing.module';
|
||||||
import { SessionGuard } from './auth/session.guard';
|
import { SessionGuard } from './auth/session.guard';
|
||||||
import { TenantGuard } from './auth/tenant.guard';
|
import { TenantGuard } from './auth/tenant.guard';
|
||||||
|
|
||||||
|
|
@ -42,6 +46,10 @@ function parseRedisConnection(redisUrl: string | undefined) {
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
TasksModule,
|
TasksModule,
|
||||||
|
IntelligenceModule,
|
||||||
|
SegmentsModule,
|
||||||
|
ResearchBriefsModule,
|
||||||
|
BriefingModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|
|
||||||
14
src/briefing/briefing.controller.ts
Normal file
14
src/briefing/briefing.controller.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { CurrentSession } from '../auth/session.decorator';
|
||||||
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||||
|
import { BriefingService } from './briefing.service';
|
||||||
|
|
||||||
|
@Controller('briefing')
|
||||||
|
export class BriefingController {
|
||||||
|
constructor(private readonly briefingService: BriefingService) {}
|
||||||
|
|
||||||
|
@Get('today')
|
||||||
|
today(@CurrentSession() session: AuthenticatedSession) {
|
||||||
|
return this.briefingService.today(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/briefing/briefing.module.ts
Normal file
9
src/briefing/briefing.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BriefingController } from './briefing.controller';
|
||||||
|
import { BriefingService } from './briefing.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BriefingController],
|
||||||
|
providers: [BriefingService],
|
||||||
|
})
|
||||||
|
export class BriefingModule {}
|
||||||
73
src/briefing/briefing.service.ts
Normal file
73
src/briefing/briefing.service.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { and, asc, eq, gte, isNull, lt, ne } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { organizations, researchBriefs, savedSegments, tasks } from '../db/schema';
|
||||||
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blueprint 17 "Daily Briefing": Scheduler -> collect tasks/deadlines -> deterministic
|
||||||
|
* priority -> AI explanation -> save -> notify. Aici implementam doar partea
|
||||||
|
* deterministica (colectare + prioritizare); explicatia AI vine cu AI Gateway-ul
|
||||||
|
* (blueprint 14), inca neconstruit -- nu simulam text generat de AI.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BriefingService {
|
||||||
|
async today(session: AuthenticatedSession) {
|
||||||
|
const now = new Date();
|
||||||
|
const weekAgo = new Date(now.getTime() - SEVEN_DAYS_MS);
|
||||||
|
const weekFromNow = new Date(now.getTime() + SEVEN_DAYS_MS);
|
||||||
|
|
||||||
|
const activeStatuses = [ne(tasks.status, 'done'), ne(tasks.status, 'cancelled')] as const;
|
||||||
|
|
||||||
|
const [overdueTasks, upcomingTasks, newOrganizations, recentSegments, recentBriefs] =
|
||||||
|
await Promise.all([
|
||||||
|
db.query.tasks.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.tenantId, session.tenantId),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
...activeStatuses,
|
||||||
|
lt(tasks.dueAt, now),
|
||||||
|
),
|
||||||
|
orderBy: asc(tasks.dueAt),
|
||||||
|
}),
|
||||||
|
db.query.tasks.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.tenantId, session.tenantId),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
...activeStatuses,
|
||||||
|
gte(tasks.dueAt, now),
|
||||||
|
lt(tasks.dueAt, weekFromNow),
|
||||||
|
),
|
||||||
|
orderBy: [asc(tasks.priority), asc(tasks.dueAt)],
|
||||||
|
}),
|
||||||
|
db.query.organizations.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(organizations.tenantId, session.tenantId),
|
||||||
|
isNull(organizations.deletedAt),
|
||||||
|
gte(organizations.createdAt, weekAgo),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
db.query.savedSegments.findMany({
|
||||||
|
where: and(eq(savedSegments.tenantId, session.tenantId), gte(savedSegments.createdAt, weekAgo)),
|
||||||
|
}),
|
||||||
|
db.query.researchBriefs.findMany({
|
||||||
|
where: and(eq(researchBriefs.tenantId, session.tenantId), gte(researchBriefs.createdAt, weekAgo)),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
overdueTasks,
|
||||||
|
upcomingTasks,
|
||||||
|
weekInReview: {
|
||||||
|
newOrganizations: newOrganizations.length,
|
||||||
|
newSegments: recentSegments.length,
|
||||||
|
newResearchBriefs: recentBriefs.length,
|
||||||
|
},
|
||||||
|
// camp explicit -- semnaleaza in UI ca lipseste inca stratul AI, nu-l ascunde
|
||||||
|
aiExplanation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -245,3 +245,33 @@ export const auditLog = pgTable('audit_log', {
|
||||||
correlationId: uuid('correlation_id'),
|
correlationId: uuid('correlation_id'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Intelligence Engine / Faza A2 (blueprint sectiunea 10.3, 17 "Lead Intelligence") ---
|
||||||
|
// Apollo/ClickHouse raman sursa de adevar pentru datele B2B (blueprint 9: "Private data !=
|
||||||
|
// B2B intelligence"); aici pastram doar referinte (organization_id extern) si curatoria
|
||||||
|
// tenantului -- cautari salvate si dovezi de research, nu o copie a datasetului.
|
||||||
|
|
||||||
|
export const savedSegments = pgTable('saved_segments', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
tenantId: uuid('tenant_id').notNull(),
|
||||||
|
createdByUserId: uuid('created_by_user_id').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
query: text('query').notNull(),
|
||||||
|
resultLimit: integer('result_limit').notNull().default(20),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// summary e curatat manual de user in absenta AI Gateway-ului (blueprint sectiunea 14);
|
||||||
|
// schema e gandita ca AI Gateway sa poata popula acelasi camp mai tarziu fara migrare
|
||||||
|
export const researchBriefs = pgTable('research_briefs', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
tenantId: uuid('tenant_id').notNull(),
|
||||||
|
createdByUserId: uuid('created_by_user_id').notNull(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
organizationName: text('organization_name'),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
summary: text('summary').notNull(),
|
||||||
|
sources: jsonb('sources').notNull().default([]),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
|
||||||
14
src/intelligence/dto.ts
Normal file
14
src/intelligence/dto.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsString, Length, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class SearchCompaniesQueryDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 200)
|
||||||
|
q!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit: number = 20;
|
||||||
|
}
|
||||||
18
src/intelligence/intelligence.controller.ts
Normal file
18
src/intelligence/intelligence.controller.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
|
import { SearchCompaniesQueryDto } from './dto';
|
||||||
|
import { IntelligenceService } from './intelligence.service';
|
||||||
|
|
||||||
|
@Controller('intelligence/companies')
|
||||||
|
export class IntelligenceController {
|
||||||
|
constructor(private readonly intelligenceService: IntelligenceService) {}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
search(@Query() query: SearchCompaniesQueryDto) {
|
||||||
|
return this.intelligenceService.searchCompanies(query.q, query.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':organizationId')
|
||||||
|
getCompany(@Param('organizationId') organizationId: string) {
|
||||||
|
return this.intelligenceService.getCompany(organizationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/intelligence/intelligence.module.ts
Normal file
10
src/intelligence/intelligence.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IntelligenceController } from './intelligence.controller';
|
||||||
|
import { IntelligenceService } from './intelligence.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [IntelligenceController],
|
||||||
|
providers: [IntelligenceService],
|
||||||
|
exports: [IntelligenceService],
|
||||||
|
})
|
||||||
|
export class IntelligenceModule {}
|
||||||
55
src/intelligence/intelligence.service.ts
Normal file
55
src/intelligence/intelligence.service.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { BadGatewayException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { CompanySearchResponse, CompanySearchResult } from './intelligence.types';
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client subtire catre intelligence-api. Blueprint 10.3/16: "company search prin
|
||||||
|
* Intelligence API, nu SQL direct" -- ceo-web nu vorbeste niciodata direct cu
|
||||||
|
* intelligence-api, ca sa putem adauga aici audit, quotas si viitorul AI Gateway
|
||||||
|
* fara sa schimbam contractul catre frontend.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class IntelligenceService {
|
||||||
|
private readonly baseUrl = process.env.INTELLIGENCE_API_URL ?? 'http://localhost:8000';
|
||||||
|
|
||||||
|
async searchCompanies(query: string, limit: number): Promise<CompanySearchResponse> {
|
||||||
|
const response = await this.request('/companies/search', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ query, limit }),
|
||||||
|
});
|
||||||
|
return (await response.json()) as CompanySearchResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCompany(organizationId: string): Promise<CompanySearchResult> {
|
||||||
|
const response = await this.request(`/companies/${encodeURIComponent(organizationId)}`);
|
||||||
|
const data = (await response.json()) as CompanySearchResult | { error: string };
|
||||||
|
if ('error' in data) {
|
||||||
|
throw new NotFoundException('Company not found in intelligence data');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new BadGatewayException('intelligence-api request failed');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BadGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new BadGatewayException('intelligence-api unreachable');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/intelligence/intelligence.types.ts
Normal file
17
src/intelligence/intelligence.types.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Forma exacta intoarsa de intelligence-api (app/routers/companies.py SEARCH_COLUMNS) --
|
||||||
|
// tinuta sincronizata manual, nu e generata; intelligence-api nu publica un schema versionat.
|
||||||
|
export interface CompanySearchResult {
|
||||||
|
organization_id: string;
|
||||||
|
organization_name: string;
|
||||||
|
normalized_domain: string;
|
||||||
|
hq_city: string;
|
||||||
|
hq_country: string;
|
||||||
|
industries: string[];
|
||||||
|
num_current_employees: number | null;
|
||||||
|
revenue_in_thousands: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySearchResponse {
|
||||||
|
results: CompanySearchResult[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
36
src/research-briefs/dto.ts
Normal file
36
src/research-briefs/dto.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, IsOptional, IsString, IsUrl, Length, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
export class SourceDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 200)
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@IsUrl()
|
||||||
|
url!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateResearchBriefDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 128)
|
||||||
|
organizationId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 250)
|
||||||
|
organizationName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 250)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 5000)
|
||||||
|
summary!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SourceDto)
|
||||||
|
sources?: SourceDto[];
|
||||||
|
}
|
||||||
30
src/research-briefs/research-briefs.controller.ts
Normal file
30
src/research-briefs/research-briefs.controller.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||||
|
import { CurrentSession } from '../auth/session.decorator';
|
||||||
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||||
|
import { CreateResearchBriefDto } from './dto';
|
||||||
|
import { ResearchBriefsService } from './research-briefs.service';
|
||||||
|
|
||||||
|
@Controller('research-briefs')
|
||||||
|
export class ResearchBriefsController {
|
||||||
|
constructor(private readonly researchBriefsService: ResearchBriefsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentSession() session: AuthenticatedSession) {
|
||||||
|
return this.researchBriefsService.list(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.researchBriefsService.getById(session, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateResearchBriefDto) {
|
||||||
|
return this.researchBriefsService.create(session, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.researchBriefsService.remove(session, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/research-briefs/research-briefs.module.ts
Normal file
9
src/research-briefs/research-briefs.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ResearchBriefsController } from './research-briefs.controller';
|
||||||
|
import { ResearchBriefsService } from './research-briefs.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ResearchBriefsController],
|
||||||
|
providers: [ResearchBriefsService],
|
||||||
|
})
|
||||||
|
export class ResearchBriefsModule {}
|
||||||
71
src/research-briefs/research-briefs.service.ts
Normal file
71
src/research-briefs/research-briefs.service.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { researchBriefs } from '../db/schema';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||||
|
import type { CreateResearchBriefDto } from './dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResearchBriefsService {
|
||||||
|
constructor(private readonly audit: AuditService) {}
|
||||||
|
|
||||||
|
async list(session: AuthenticatedSession) {
|
||||||
|
return db.query.researchBriefs.findMany({
|
||||||
|
where: eq(researchBriefs.tenantId, session.tenantId),
|
||||||
|
orderBy: desc(researchBriefs.createdAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(session: AuthenticatedSession, id: string) {
|
||||||
|
const brief = await db.query.researchBriefs.findFirst({
|
||||||
|
where: and(eq(researchBriefs.id, id), eq(researchBriefs.tenantId, session.tenantId)),
|
||||||
|
});
|
||||||
|
if (!brief) {
|
||||||
|
throw new NotFoundException('Research brief not found');
|
||||||
|
}
|
||||||
|
return brief;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(session: AuthenticatedSession, dto: CreateResearchBriefDto) {
|
||||||
|
// Provenance obligatoriu (blueprint 9.1 Evidence & Provenance Fabric): chiar in
|
||||||
|
// versiunea curatata manual, fiecare brief indica cel putin sursa Apollo/intelligence-api.
|
||||||
|
const sources = [
|
||||||
|
{ label: 'Apollo / intelligence-api', url: 'https://intelligence-api.boardmind.dev' },
|
||||||
|
...(dto.sources ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(researchBriefs)
|
||||||
|
.values({
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
createdByUserId: session.userId,
|
||||||
|
organizationId: dto.organizationId,
|
||||||
|
organizationName: dto.organizationName,
|
||||||
|
title: dto.title,
|
||||||
|
summary: dto.summary,
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await this.audit.record(null, {
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
actorId: session.userId,
|
||||||
|
action: 'research_brief.created',
|
||||||
|
resource: `research_brief:${row.id}`,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(session: AuthenticatedSession, id: string) {
|
||||||
|
const brief = await this.getById(session, id);
|
||||||
|
await db.delete(researchBriefs).where(eq(researchBriefs.id, brief.id));
|
||||||
|
await this.audit.record(null, {
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
actorId: session.userId,
|
||||||
|
action: 'research_brief.deleted',
|
||||||
|
resource: `research_brief:${id}`,
|
||||||
|
});
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/segments/dto.ts
Normal file
16
src/segments/dto.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { IsInt, IsString, Length, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSegmentDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 200)
|
||||||
|
query!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
resultLimit: number = 20;
|
||||||
|
}
|
||||||
30
src/segments/segments.controller.ts
Normal file
30
src/segments/segments.controller.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||||
|
import { CurrentSession } from '../auth/session.decorator';
|
||||||
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||||
|
import { CreateSegmentDto } from './dto';
|
||||||
|
import { SegmentsService } from './segments.service';
|
||||||
|
|
||||||
|
@Controller('segments')
|
||||||
|
export class SegmentsController {
|
||||||
|
constructor(private readonly segmentsService: SegmentsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentSession() session: AuthenticatedSession) {
|
||||||
|
return this.segmentsService.list(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateSegmentDto) {
|
||||||
|
return this.segmentsService.create(session, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/run')
|
||||||
|
run(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.segmentsService.run(session, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.segmentsService.remove(session, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/segments/segments.module.ts
Normal file
11
src/segments/segments.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IntelligenceModule } from '../intelligence/intelligence.module';
|
||||||
|
import { SegmentsController } from './segments.controller';
|
||||||
|
import { SegmentsService } from './segments.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [IntelligenceModule],
|
||||||
|
controllers: [SegmentsController],
|
||||||
|
providers: [SegmentsService],
|
||||||
|
})
|
||||||
|
export class SegmentsModule {}
|
||||||
72
src/segments/segments.service.ts
Normal file
72
src/segments/segments.service.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { savedSegments } from '../db/schema';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { IntelligenceService } from '../intelligence/intelligence.service';
|
||||||
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||||
|
import type { CreateSegmentDto } from './dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SegmentsService {
|
||||||
|
constructor(
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly intelligence: IntelligenceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(session: AuthenticatedSession) {
|
||||||
|
return db.query.savedSegments.findMany({
|
||||||
|
where: eq(savedSegments.tenantId, session.tenantId),
|
||||||
|
orderBy: desc(savedSegments.createdAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(session: AuthenticatedSession, dto: CreateSegmentDto) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(savedSegments)
|
||||||
|
.values({
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
createdByUserId: session.userId,
|
||||||
|
name: dto.name,
|
||||||
|
query: dto.query,
|
||||||
|
resultLimit: dto.resultLimit,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
await this.audit.record(null, {
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
actorId: session.userId,
|
||||||
|
action: 'segment.created',
|
||||||
|
resource: `segment:${row.id}`,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-ruleaza cautarea salvata contra intelligence-api si intoarce rezultate proaspete. */
|
||||||
|
async run(session: AuthenticatedSession, id: string) {
|
||||||
|
const segment = await this.getById(session, id);
|
||||||
|
const results = await this.intelligence.searchCompanies(segment.query, segment.resultLimit);
|
||||||
|
return { segment, ...results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(session: AuthenticatedSession, id: string) {
|
||||||
|
const segment = await this.getById(session, id);
|
||||||
|
await db.delete(savedSegments).where(eq(savedSegments.id, segment.id));
|
||||||
|
await this.audit.record(null, {
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
actorId: session.userId,
|
||||||
|
action: 'segment.deleted',
|
||||||
|
resource: `segment:${id}`,
|
||||||
|
});
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getById(session: AuthenticatedSession, id: string) {
|
||||||
|
const segment = await db.query.savedSegments.findFirst({
|
||||||
|
where: and(eq(savedSegments.id, id), eq(savedSegments.tenantId, session.tenantId)),
|
||||||
|
});
|
||||||
|
if (!segment) {
|
||||||
|
throw new NotFoundException('Segment not found');
|
||||||
|
}
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue