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,
|
||||
"tag": "0001_lovely_brood",
|
||||
"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 { TasksModule } from './tasks/tasks.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 { TenantGuard } from './auth/tenant.guard';
|
||||
|
||||
|
|
@ -42,6 +46,10 @@ function parseRedisConnection(redisUrl: string | undefined) {
|
|||
TenantsModule,
|
||||
OrganizationsModule,
|
||||
TasksModule,
|
||||
IntelligenceModule,
|
||||
SegmentsModule,
|
||||
ResearchBriefsModule,
|
||||
BriefingModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
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'),
|
||||
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