mirror of
https://github.com/dadosfera/maestro.git
synced 2026-09-01 12:18:15 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d04c1d495 |
@@ -1,12 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
coverage
|
||||
.nyc_output
|
||||
*.tgz
|
||||
!protospack.tgz
|
||||
@@ -56,9 +56,9 @@ jobs:
|
||||
|
||||
- name: Install Helmfile
|
||||
run: |
|
||||
curl -fsSLO https://github.com/helmfile/helmfile/releases/download/v0.148.0/helmfile_0.148.0_linux_amd64.tar.gz
|
||||
wget https://github.com/helmfile/helmfile/releases/download/v0.148.0/helmfile_0.148.0_linux_amd64.tar.gz
|
||||
tar -xzf helmfile_0.148.0_linux_amd64.tar.gz
|
||||
sudo mv helmfile /usr/local/bin/
|
||||
mv helmfile /usr/local/bin/
|
||||
helmfile --version
|
||||
|
||||
- name: Install Helm Diff Plugin
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Install Helmfile
|
||||
run: |
|
||||
curl -fsSLO https://github.com/helmfile/helmfile/releases/download/v0.148.0/helmfile_0.148.0_linux_amd64.tar.gz
|
||||
wget https://github.com/helmfile/helmfile/releases/download/v0.148.0/helmfile_0.148.0_linux_amd64.tar.gz
|
||||
tar -xzf helmfile_0.148.0_linux_amd64.tar.gz
|
||||
sudo mv helmfile /usr/local/bin/
|
||||
helmfile --version
|
||||
|
||||
@@ -2,21 +2,9 @@ name: Test
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- beta
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# Blocks a local (file:/tarball/overlay) protospack-v2 dependency from
|
||||
# reaching staging (beta) or prod (main).
|
||||
protospack-dep-guard:
|
||||
if: github.base_ref == 'beta' || github.base_ref == 'main'
|
||||
runs-on: [self-hosted, prd]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Check protospack-v2 is consumed from the registry
|
||||
run: node scripts/check-protospack-dep.js
|
||||
|
||||
test:
|
||||
runs-on: [self-hosted, prd]
|
||||
env:
|
||||
|
||||
@@ -66,21 +66,13 @@ jobs:
|
||||
|
||||
- name: Install Helmfile
|
||||
run: |
|
||||
curl -fsSLO https://github.com/helmfile/helmfile/releases/download/v0.148.0/helmfile_0.148.0_linux_amd64.tar.gz
|
||||
wget https://github.com/helmfile/helmfile/releases/download/v0.148.0/helmfile_0.148.0_linux_amd64.tar.gz
|
||||
tar -xzf helmfile_0.148.0_linux_amd64.tar.gz
|
||||
sudo mv helmfile /usr/local/bin/
|
||||
helmfile --version
|
||||
|
||||
- name: Install Helm Diff plugin
|
||||
run: |
|
||||
helm plugin install https://github.com/databus23/helm-diff --version v3.9.3
|
||||
helm diff version
|
||||
|
||||
- name: Debug Helm env
|
||||
run: |
|
||||
helm env
|
||||
echo "HOME=$HOME"
|
||||
ls -R $HOME/.local/share/helm || true
|
||||
- name: Install Helm Diff Plugin
|
||||
run: helm plugin install https://github.com/databus23/helm-diff || true
|
||||
|
||||
- name: Authenticate with OKE cluster
|
||||
env:
|
||||
@@ -107,5 +99,4 @@ jobs:
|
||||
- name: Run Helmfile Diff
|
||||
env:
|
||||
ENV: ${{ needs.extract_environment.outputs.environment }}
|
||||
HELM_PLUGINS: /home/runner/.local/share/helm/plugins
|
||||
run: helmfile -f deploy/helmfiles/${ENV}.yaml diff
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# maestro — development policy
|
||||
|
||||
Maestro is the NestJS BFF between the Angular frontend and the gRPC services
|
||||
(pi-factory, in-factory) / platform-api. These rules come from code review and
|
||||
apply to every change; the same rules live in pi-factory and in-factory.
|
||||
|
||||
## Layering
|
||||
- Controllers are thin: decorators, body validation, one call into a service,
|
||||
response shape. Orchestration (multi-step calls, rollbacks, platform-api or
|
||||
gRPC round-trips) lives in a `*.service.ts`. Example:
|
||||
`platform-api/pipeline-tables.service.ts`.
|
||||
- One platform-api route per operation. If the platform already dispatches by
|
||||
pipeline type (batch vs CDC), do not branch on the type here — call the
|
||||
route that dispatches (e.g. `DELETE /pipeline/{id}/jobs` for table removal,
|
||||
never `DELETE /jobs/{id}` from maestro).
|
||||
|
||||
## Types
|
||||
- No `any` / `as any`. gRPC calls take the protospack request type exactly
|
||||
(`AddCdcTableRequest`, `MarkTableDeletedRequest`, ...); DTOs and interfaces
|
||||
are explicit classes/interfaces. Use `Record<string, T>` or `unknown` (with
|
||||
narrowing) when a shape is genuinely open — never `any`.
|
||||
- Protospack payloads are built in one mapper per entity (e.g.
|
||||
`inputs/cdc-table.mapper.ts`), so a field that mirrors another
|
||||
(`CdcTable.name` == `table_name`) is derived in exactly one place.
|
||||
|
||||
## Pipeline type
|
||||
- Decide CDC vs batch with `src/utils/cdc.ts` (`isCdcPlugin`, `isCdcJob`,
|
||||
`isCdcPipeline`), never with inline `plugin.endsWith('_cdc')`, `!!x`, or
|
||||
the absence of some other data (e.g. "no run history ⇒ editable").
|
||||
- The platform-api stamps `job.input.connector === 'cdc'` on CDC jobs; that
|
||||
is the authoritative discriminator once a pipeline exists.
|
||||
|
||||
## Dependencies
|
||||
- `@dadosfera/protospack-v2` is consumed from CodeArtifact, pinned to an exact
|
||||
version (`npm i @dadosfera/protospack-v2@<version> --save-exact`). A `file:`
|
||||
/ tarball reference is for local development only and must never be
|
||||
committed. Note: `^3.40.0-beta.N` resolves to the stable `3.40.0` —
|
||||
prereleases must be pinned exactly.
|
||||
|
||||
## Commits and releases
|
||||
- Deploys are cut by semantic-release with the eslint preset: the commit
|
||||
title MUST start with `FIX:` (patch), `UPDATE:` or `FEAT:` (minor). A
|
||||
lowercase `feat(scope): ...` merges without producing a version, so the
|
||||
code never reaches stg/prd.
|
||||
- Pushing to `beta` deploys stg; `main` deploys prd.
|
||||
|
||||
## Tests
|
||||
- Every service method with a rollback path has a spec covering the
|
||||
happy path, the platform failure (rollback fires) and a rollback failure
|
||||
(does not mask the original error). Run `npx jest <path>` for a folder,
|
||||
`npx tsc --noEmit -p tsconfig.json` for types.
|
||||
- Several gRPC client configs read `process.env` at import time, so the full
|
||||
suite needs the service URLs set (any `0.0.0.0:<port>` value works):
|
||||
`DUC_URL=0.0.0.0:50051 INFACTORY_URL=0.0.0.0:50052 PIFACTORY_URL=0.0.0.0:50053 npx jest`.
|
||||
A `Cannot read properties of undefined (reading 'startsWith')` at import is
|
||||
this, not a broken test.
|
||||
+3
-5
@@ -1,5 +1,4 @@
|
||||
FROM node:20-alpine AS base_image
|
||||
RUN npm install -g npm@10.8.2
|
||||
FROM node:18.17-alpine AS base_image
|
||||
|
||||
FROM base_image AS build_base
|
||||
WORKDIR /app
|
||||
@@ -22,14 +21,13 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
# run aws cli without mounting secret, because CI already has AWS credentials
|
||||
FROM build_base AS ci_image
|
||||
RUN aws codeartifact login --tool npm --namespace @dadosfera --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1
|
||||
RUN npm ci --ignore-scripts
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
|
||||
# unit test specific build
|
||||
FROM ci_image AS test
|
||||
ENV DUC_URL=0.0.0.0:50051
|
||||
ENV INFACTORY_URL=0.0.0.0:50052
|
||||
ENTRYPOINT ["npm", "run", "test"]
|
||||
|
||||
|
||||
@@ -38,7 +36,7 @@ FROM build_base AS dev
|
||||
RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \
|
||||
aws codeartifact login --tool npm --namespace @dadosfera --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1
|
||||
# flag --build-from-source is required to force-build sqlite3
|
||||
RUN npm ci --ignore-scripts
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENTRYPOINT npm run start:dev
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
FROM node:22-alpine AS base_image
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
FROM base_image AS build_base
|
||||
WORKDIR /app
|
||||
RUN apk update
|
||||
RUN apk add --no-cache \
|
||||
aws-cli \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont
|
||||
COPY package*.json ./
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
|
||||
# Local build with secrets
|
||||
FROM build_base AS build
|
||||
RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \
|
||||
aws codeartifact login --tool npm --namespace @dadosfera --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM base_image
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package*.json ./
|
||||
RUN apk update
|
||||
RUN apk add --no-cache \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
ENTRYPOINT ["npm", "run", "start:prod"]
|
||||
@@ -2,8 +2,8 @@
|
||||
<image src="./assets/maestro.svg" style="width:10rem">
|
||||
</p>
|
||||
|
||||
# Maestro
|
||||
|
||||
# Maestro
|
||||
|
||||
Maestro é a API principal da Dadosfera. É responsável pela comunicação do Frontend com nossos microsserviços.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -48,9 +48,6 @@ spec:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
# Auth Provider Configuration (cognito or keycloak)
|
||||
- name: AUTH_PROVIDER
|
||||
value: {{ .Values.maestro.auth_provider | default "cognito" | quote }}
|
||||
- name: AWS_IDENTITY_POOL_ID
|
||||
value: {{ .Values.maestro.aws_identity_pool_id }}
|
||||
- name: AWS_REGION
|
||||
@@ -107,16 +104,6 @@ spec:
|
||||
value: {{ .Values.maestro.redis_host }}
|
||||
- name: REDIS_PORT
|
||||
value: "{{ .Values.maestro.redis_port }}"
|
||||
- name: REDIS_TLS
|
||||
value: "{{ .Values.maestro.redis_tls }}"
|
||||
- name: PLATFORM_API_URL
|
||||
value: {{ .Values.maestro.platform_api_url }}
|
||||
- name: CONNECTIONS_API_URL
|
||||
value: {{ .Values.maestro.connections_api_url | default "" | quote }}
|
||||
- name: STORAGE_EXPLORER_API_URL
|
||||
value: {{ .Values.maestro.storage_explorer_api_url | quote }}
|
||||
- name: FIREBASE_BASE_URL
|
||||
value: {{ .Values.maestro.firebase_base_url }}
|
||||
- name: JWT_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -137,14 +124,3 @@ spec:
|
||||
secretKeyRef:
|
||||
name: prd-{{ .Values.app_name }}
|
||||
key: AWS_DEFAULT_REGION
|
||||
# Elasticsearch
|
||||
- name: ELASTICSEARCH_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: prd-{{ .Values.app_name }}
|
||||
key: ELASTICSEARCH_URL
|
||||
- name: ELASTICSEARCH_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: prd-{{ .Values.app_name }}
|
||||
key: ELASTICSEARCH_API_KEY
|
||||
|
||||
@@ -4,18 +4,9 @@ metadata:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/whitelist-source-range: "69.49.241.121/32" # hostgator ip
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/server-snippet: |
|
||||
underscores_in_headers on;
|
||||
ignore_invalid_headers on;
|
||||
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
|
||||
nginx.ingress.kubernetes.io/proxy-buffers-number: "8"
|
||||
nginx.ingress.kubernetes.io/proxy-busy-buffers-size: "64k"
|
||||
{{- if .Values.maestro.restricted_ip}}
|
||||
nginx.ingress.kubernetes.io/whitelist-source-range: {{ .Values.maestro.restricted_ip }}
|
||||
{{- end }}
|
||||
|
||||
generation: 1
|
||||
labels:
|
||||
|
||||
@@ -38,15 +38,3 @@ spec:
|
||||
version: "AWSCURRENT"
|
||||
property: token
|
||||
|
||||
- secretKey: ELASTICSEARCH_URL
|
||||
remoteRef:
|
||||
key: {{ .Values.maestro.env }}/microservices/elasticsearch
|
||||
version: "AWSCURRENT"
|
||||
property: ELASTICSEARCH_URL
|
||||
|
||||
- secretKey: ELASTICSEARCH_API_KEY
|
||||
remoteRef:
|
||||
key: {{ .Values.maestro.env }}/microservices/elasticsearch
|
||||
version: "AWSCURRENT"
|
||||
property: ELASTICSEARCH_API_KEY
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ maestro:
|
||||
open_group_id: e3f98a2f-7748-4981-8505-7695c8ca8218
|
||||
cookie_secret: "ff7bc13823edb2ae50d248e5780bddc9d4b31c36"
|
||||
redis_database: "1"
|
||||
platform_api_url: https://xs2hkhq07k.execute-api.us-east-1.amazonaws.com
|
||||
connections_api_url: https://iy40eans64.execute-api.us-east-1.amazonaws.com
|
||||
storage_explorer_api_url: "http://storage-explorer-{customer}.data-apps.svc.cluster.local:8000/api"
|
||||
firebase_base_url: https://feature-flag-25bf6-default-rtdb.firebaseio.com/stg
|
||||
|
||||
hostname: maestro.stg.dadosfera.ai
|
||||
|
||||
|
||||
@@ -27,9 +27,6 @@ resources:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
maestro:
|
||||
# Auth provider: "cognito" (default) or "keycloak"
|
||||
# Note: maestro doesn't connect to Keycloak directly, only duc does
|
||||
auth_provider: "cognito"
|
||||
aws_identity_pool_id: "us-east-1_Mrezsw9Sn"
|
||||
duc_url: duc.dadosfera.ai
|
||||
in_factory_url: in-factory.dadosfera.ai
|
||||
@@ -46,16 +43,12 @@ maestro:
|
||||
upload_file_agent_connection: cbc2f881-58c4-4d60-8003-0979b0b5b911
|
||||
open_customer_id: f239718a-a271-4ef9-ae7e-02a2f0f3aa6e
|
||||
open_group_id: 401573bb-334f-44b2-b30e-88d4cea31ae9
|
||||
platform_api_url: https://oz8v2zid1e.execute-api.us-east-1.amazonaws.com
|
||||
storage_explorer_api_url: "https://storage-explorer-{customer}.dadosfera.ai/api"
|
||||
dedicated_proxy: ""
|
||||
restricted_ip: ""
|
||||
redis_host: "aaapzppmlyamkocqwstpo7zvopczyyiyuy6xzm2g6c5k4mq3a66be4a-0.redis.sa-saopaulo-1.oci.oraclecloud.com"
|
||||
redis_port: "6379"
|
||||
redis_database: "0"
|
||||
redis_tls: "true"
|
||||
cookie_secret: "13cc5e136d3074bcc05bec8697092ec1f5f376bf"
|
||||
firebase_base_url: https://feature-flag-25bf6-default-rtdb.firebaseio.com/prd
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
|
||||
+748
-3898
File diff suppressed because it is too large
Load Diff
Vendored
+8
-1
@@ -16,7 +16,14 @@ declare global {
|
||||
OPEN_CUSTOMER_ID: string;
|
||||
DEDICATED_PROXY: string;
|
||||
COOKIE_SECRET: string;
|
||||
REDIS_TLS?: string;
|
||||
|
||||
// Autodrive Configuration
|
||||
AUTODRIVE_USERNAME?: string;
|
||||
AUTODRIVE_PASSWORD?: string;
|
||||
AUTODRIVE_BASE_URL?: string;
|
||||
AUTODRIVE_MODEL?: string;
|
||||
AUTODRIVE_KEY?: string;
|
||||
AUTO_DRIVE_KEY?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1772
-2581
File diff suppressed because it is too large
Load Diff
+6
-20
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"co:login": "aws codeartifact login --tool npm --namespace @dadosfera --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1",
|
||||
"proto-update": "npm i @dadosfera/protospack-v2@v3.40.0-beta.1 --save-exact",
|
||||
"proto-update": "npm i @dadosfera/protospack-v2@latest --save-exact",
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -27,14 +27,10 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "^5.2.0",
|
||||
"@aws-sdk/client-dynamodb": "^3.414.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.414.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.940.0",
|
||||
"@aws-sdk/lib-dynamodb": "^3.414.0",
|
||||
"@aws-sdk/signature-v4": "^3.370.0",
|
||||
"@dadosfera/dadosfera-logs": "^1.0.0-beta.4",
|
||||
"@dadosfera/protospack-v2": "3.40.0-beta.21",
|
||||
"@dadosfera/protospack": "2.5.3",
|
||||
"@dadosfera/protospack-v2": "3.38.0-beta.10",
|
||||
"@grpc/grpc-js": "^1.9.3",
|
||||
"@grpc/proto-loader": "^0.7.9",
|
||||
"@nestjs/cli": "^9.5.0",
|
||||
@@ -48,7 +44,7 @@
|
||||
"@nestjs/schematics": "^9.2.0",
|
||||
"@nestjs/swagger": "^6.3.0",
|
||||
"@nestjs/testing": "^9.4.3",
|
||||
"axios": "0.30.3",
|
||||
"axios": "^0.27.2",
|
||||
"cache-manager": "^5.1.4",
|
||||
"cache-manager-ioredis-yet": "^1.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -64,7 +60,6 @@
|
||||
"jwk-to-pem": "^2.0.5",
|
||||
"mixpanel": "^0.17.0",
|
||||
"ms": "^3.0.0-canary.1",
|
||||
"multer": "^2.0.2",
|
||||
"openid-client": "^5.7.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-facebook": "^3.0.0",
|
||||
@@ -80,17 +75,11 @@
|
||||
"swagger-ui-express": "^4.6.3"
|
||||
},
|
||||
"overrides": {
|
||||
"axios": "0.30.3",
|
||||
"form-data": "^4.0.4",
|
||||
"body-parser": "^1.20.3",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"glob": "^10.5.0",
|
||||
"path-to-regexp": "^3.3.0",
|
||||
"semver": "^7.5.2"
|
||||
"multer": "1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cache-manager": "^4.0.6",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cache-manager": "^4.0.6",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/jest": "27.0.2",
|
||||
@@ -117,8 +106,5 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^3.14.2",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"axios": "0.30.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* CI guard: fail if @dadosfera/protospack-v2 is consumed from a LOCAL ref
|
||||
* (file:/link:/git/relative path/bare tarball) instead of the CodeArtifact
|
||||
* registry.
|
||||
*
|
||||
* Only local consumption is blocked. Versions published to CodeArtifact —
|
||||
* including alpha/beta/rc prereleases produced by the alpha/beta branches —
|
||||
* are fine; those resolve to a registry URL in the lockfile. The thing that
|
||||
* must NOT reach beta (staging) or main (prod) is a dependency wired to a
|
||||
* local `npm pack` tarball / overlay. Runs in the PR test workflow for PRs
|
||||
* targeting beta/main and exits non-zero on any local ref.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PKG = '@dadosfera/protospack-v2';
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
|
||||
const problems = [];
|
||||
|
||||
// A dependency SPEC is local if it's a filesystem path, symlink, git ref, or a
|
||||
// bare tarball path. A plain semver (incl. prereleases like 3.35.0-beta.1)
|
||||
// resolves from the registry and is allowed.
|
||||
function isLocalSpec(spec) {
|
||||
return /^(file:|link:|git[:+]|\.\.?\/|\/|~\/)/.test(spec) || spec.endsWith('.tgz');
|
||||
}
|
||||
|
||||
const spec =
|
||||
(pkg.dependencies && pkg.dependencies[PKG]) ||
|
||||
(pkg.devDependencies && pkg.devDependencies[PKG]);
|
||||
|
||||
if (!spec) {
|
||||
problems.push(`${PKG} is not listed as a dependency at all.`);
|
||||
} else if (isLocalSpec(spec)) {
|
||||
problems.push(`${PKG} points at a local path/tarball/git ref: "${spec}".`);
|
||||
}
|
||||
|
||||
// Also catch a lockfile resolved to a LOCAL ref even if package.json looks
|
||||
// clean. A registry URL (https://.../-/*.tgz) is the normal published
|
||||
// resolution and is fine — only file: refs and bare local tarball paths
|
||||
// (no http host) are blocked. Prerelease VERSIONS are not flagged: an
|
||||
// alpha/beta/rc published to CodeArtifact resolves to a registry URL.
|
||||
const lockPath = path.join(root, 'package-lock.json');
|
||||
if (fs.existsSync(lockPath)) {
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
||||
const nodes = { ...(lock.packages || {}), ...(lock.dependencies || {}) };
|
||||
for (const [name, node] of Object.entries(nodes)) {
|
||||
if (!name.includes('protospack-v2') || !node) continue;
|
||||
const resolved = node.resolved || '';
|
||||
const isLocal =
|
||||
resolved.startsWith('file:') ||
|
||||
(resolved.endsWith('.tgz') && !/^https?:\/\//.test(resolved));
|
||||
if (isLocal) {
|
||||
problems.push(
|
||||
`package-lock.json resolves ${PKG} to a local ref: "${resolved}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error('✗ protospack-v2 dependency guard FAILED:');
|
||||
for (const p of problems) console.error(' - ' + p);
|
||||
console.error(
|
||||
'\nMerging to beta/main requires ' +
|
||||
PKG +
|
||||
' to come from CodeArtifact, not a local tarball/overlay. Publish ' +
|
||||
'protospack-v2 (a beta prerelease is fine for the beta branch) and ' +
|
||||
'repoint this dependency before merging.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ ${PKG} is consumed from the registry: "${spec}"`);
|
||||
+2
-7
@@ -17,6 +17,7 @@ import { ConnectionTestModule } from './modules/connection-test/connection-test.
|
||||
import { NetworkConfigModule } from './modules/network-config/network-config.module';
|
||||
import { InputsModule } from './modules/inputs/inputs.module';
|
||||
import { OauthModule } from './modules/oauth/oauth.module';
|
||||
import { PipelinesModule } from './modules/pipelines/pipelines.module';
|
||||
import { TransformationsModule } from './modules/transformations/transformations.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
@@ -32,10 +33,6 @@ import { NetworkPolicyModule } from './modules/network-policy/network-policy.mod
|
||||
import { AssignModule } from './modules/assign/assign.module';
|
||||
import { ShareMetadataModule } from './modules/share-metadata/share-metadata.module';
|
||||
import { ApiKeyModule } from './modules/api-key/api-key.module';
|
||||
import { PlatformApiModule } from './modules/platform-api/platform-api.module';
|
||||
import { StorageExplorerModule } from './modules/storage-explorer/storage-explorer.module';
|
||||
import { ReleaseNoteModule } from './modules/release_note/release_note.module';
|
||||
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -59,6 +56,7 @@ import { ReleaseNoteModule } from './modules/release_note/release_note.module';
|
||||
PermissionsModule,
|
||||
TermsOfUseModule,
|
||||
ConnectionTestModule,
|
||||
PipelinesModule,
|
||||
TransformationsModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
@@ -75,11 +73,8 @@ import { ReleaseNoteModule } from './modules/release_note/release_note.module';
|
||||
ApiKeyModule,
|
||||
IdentityProviderModule,
|
||||
NetworkPolicyModule,
|
||||
PlatformApiModule,
|
||||
StorageExplorerModule,
|
||||
//Always leave HealthModule last, so it is on the bottom of swagger
|
||||
HealthModule,
|
||||
ReleaseNoteModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -153,7 +153,6 @@ export class AuthenticationGuard
|
||||
user_id: accessTokenPayload.user_id,
|
||||
username: accessTokenPayload.username,
|
||||
permissions: accessTokenPayload.permissions,
|
||||
roles: accessTokenPayload.roles,
|
||||
customer_id: accessTokenPayload.customer_id,
|
||||
customer_name: accessTokenPayload.customer_name,
|
||||
customer_tier: accessTokenPayload.customer_tier,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
|
||||
export function extractUserFrom(aRawJwt: string) {
|
||||
const decodedToken = jwt.decode(aRawJwt, {
|
||||
complete: true,
|
||||
});
|
||||
|
||||
const payload = decodedToken.payload as JwtPayload;
|
||||
|
||||
return {
|
||||
user_id: payload.user_id,
|
||||
username: payload.username,
|
||||
permissions: payload.permissions,
|
||||
roles: payload.roles,
|
||||
customer_id: payload.customer_id,
|
||||
customer_name: payload.customer_name,
|
||||
customer_tier: payload.customer_tier,
|
||||
customer_modules: payload.customer_modules,
|
||||
access_token: aRawJwt,
|
||||
}
|
||||
}
|
||||
@@ -116,44 +116,6 @@ export const PERMISSIONS_GROUPS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
IMPORT_FILES: {
|
||||
title: {
|
||||
'pt-br': 'Coletar | Importar arquivos',
|
||||
'en-us': 'Collect | Import files',
|
||||
'es-es': 'Colecta | Importar archivos',
|
||||
},
|
||||
permissions: {
|
||||
VIEW: {
|
||||
seqid: 48,
|
||||
claim: 'import-file:view',
|
||||
usage: PermissionUsages.PUBLIC,
|
||||
name: {
|
||||
'pt-br': 'Importar arquivos',
|
||||
'en-us': 'Import files',
|
||||
'es-es': 'Importar archivos',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AI_CHAT: {
|
||||
title: {
|
||||
'pt-br': 'AutodriveDDF',
|
||||
'en-us': 'AutodriveDDF',
|
||||
'es-es': 'AutodriveDDF',
|
||||
},
|
||||
permissions: {
|
||||
VIEW: {
|
||||
seqid: 49,
|
||||
claim: 'ai-chat:view',
|
||||
usage: PermissionUsages.PUBLIC,
|
||||
name: {
|
||||
'pt-br': 'AutodriveDDF',
|
||||
'en-us': 'AutodriveDDF',
|
||||
'es-es': 'AutodriveDDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CONNECTION: {
|
||||
title: {
|
||||
'pt-br': 'Coletar | Fontes de dados',
|
||||
@@ -357,16 +319,6 @@ export const PERMISSIONS_GROUPS = {
|
||||
'es-es': 'Crear y editar atributos en el catálogo',
|
||||
},
|
||||
},
|
||||
CERTIFY: {
|
||||
seqid: 53,
|
||||
claim: 'catalog:certify',
|
||||
usage: PermissionUsages.PUBLIC,
|
||||
name: {
|
||||
'pt-br': 'Alterar o status de certificação dos Ativos',
|
||||
'en-us': "Change Assets' certification status",
|
||||
'es-es': 'Cambiar el estado de certificación de los Activos',
|
||||
},
|
||||
},
|
||||
DELETE: {
|
||||
seqid: 1,
|
||||
claim: 'catalog:delete',
|
||||
@@ -400,25 +352,6 @@ export const PERMISSIONS_GROUPS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
LINEAGE: {
|
||||
title: {
|
||||
'pt-br': 'Explorar | Linhagem',
|
||||
'en-us': 'Explore | Lineage',
|
||||
'es-es': 'Explorar | Linaje',
|
||||
},
|
||||
permissions: {
|
||||
VIEW: {
|
||||
seqid: 50,
|
||||
claim: 'lineage:view',
|
||||
usage: PermissionUsages.PUBLIC,
|
||||
name: {
|
||||
'pt-br': 'Acessar ao módulo de Linhagem',
|
||||
'en-us': 'Access to Lineage module',
|
||||
'es-es': 'Acceda al módulo de Linaje',
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
EMBED: {
|
||||
title: {
|
||||
'pt-br': 'Analisar | Incorporação',
|
||||
@@ -678,35 +611,6 @@ export const PERMISSIONS_GROUPS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
STORAGE_EXPLORER: {
|
||||
title: {
|
||||
'pt-br': 'Storage Explorer',
|
||||
'en-us': 'Storage Explorer',
|
||||
'es-es': 'Storage Explorer',
|
||||
},
|
||||
permissions: {
|
||||
READ: {
|
||||
seqid: 51,
|
||||
claim: 'storage-explorer:read',
|
||||
usage: PermissionUsages.PUBLIC,
|
||||
name: {
|
||||
'pt-br': 'Ler dados do Storage Explorer',
|
||||
'en-us': 'Read Storage Explorer data',
|
||||
'es-es': 'Leer datos del Storage Explorer',
|
||||
},
|
||||
},
|
||||
WRITE: {
|
||||
seqid: 52,
|
||||
claim: 'storage-explorer:write',
|
||||
usage: PermissionUsages.PUBLIC,
|
||||
name: {
|
||||
'pt-br': 'Escrever dados no Storage Explorer',
|
||||
'en-us': 'Write Storage Explorer data',
|
||||
'es-es': 'Escribir datos en Storage Explorer',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export interface DadosferaModule {
|
||||
name: string;
|
||||
@@ -722,8 +626,6 @@ export const DADOSFERA_MODULES_KEYS = {
|
||||
PII: 'pii',
|
||||
EMBED: 'embedded-analytics',
|
||||
EMBED_ASSIGNED: 'embed-assigned',
|
||||
CATALOG: 'catalog',
|
||||
COLLECT: 'collect',
|
||||
}
|
||||
|
||||
export const DADOSFERA_MODULES: Array<DadosferaModule> = [
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface RequestUser {
|
||||
customer_tier: string;
|
||||
access_token: string;
|
||||
customer_modules: string[];
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export const User: (options?: { required?: boolean }) => ParameterDecorator =
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PipelineExecutionGuard } from './pipeline-execution.guard';
|
||||
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
|
||||
type Routes = {
|
||||
pipeline?: unknown | Error;
|
||||
runs?: unknown | Error;
|
||||
};
|
||||
|
||||
// The guard makes up to two platform-api reads: GET /pipeline/{id} (type)
|
||||
// and, for batch only, GET /pipeline/{id}/pipeline_run (last run).
|
||||
function buildGuard(routes: Routes) {
|
||||
const proxy = jest.fn((method: string, path: string) => {
|
||||
const answer = path.endsWith('/pipeline_run') ? routes.runs : routes.pipeline;
|
||||
return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer);
|
||||
});
|
||||
const guard = new PipelineExecutionGuard(
|
||||
{ logger } as unknown as ConstructorParameters<typeof PipelineExecutionGuard>[0],
|
||||
{ proxy } as unknown as ConstructorParameters<typeof PipelineExecutionGuard>[1],
|
||||
);
|
||||
return { guard, proxy };
|
||||
}
|
||||
|
||||
function contextWith(pipelineId = 'abc-123') {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ params: { pipelineId }, user: {} }),
|
||||
}),
|
||||
} as unknown as Parameters<PipelineExecutionGuard['canActivate']>[0];
|
||||
}
|
||||
|
||||
const cdcPipeline = { jobs: [{ job_id: 'p_0', input: { connector: 'cdc', table_name: 't' } }] };
|
||||
const batchPipeline = { jobs: [{ job_id: 'p_0', input: { connector: 'jdbc', table_name: 't' } }] };
|
||||
|
||||
describe('PipelineExecutionGuard', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('CDC pipelines (explicit connector type)', () => {
|
||||
it('allows the edit without consulting run history', async () => {
|
||||
const { guard, proxy } = buildGuard({ pipeline: cdcPipeline });
|
||||
await expect(guard.canActivate(contextWith())).resolves.toBe(true);
|
||||
expect(proxy).toHaveBeenCalledTimes(1);
|
||||
expect(proxy).toHaveBeenCalledWith('GET', '/pipeline/abc_123', {});
|
||||
});
|
||||
|
||||
it('is CDC when any job is a CDC job', async () => {
|
||||
const mixed = { jobs: [...batchPipeline.jobs, ...cdcPipeline.jobs] };
|
||||
const { guard, proxy } = buildGuard({ pipeline: mixed });
|
||||
await expect(guard.canActivate(contextWith())).resolves.toBe(true);
|
||||
expect(proxy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch pipelines', () => {
|
||||
it('allows the edit when the pipeline never ran (empty run history)', async () => {
|
||||
const { guard } = buildGuard({ pipeline: batchPipeline, runs: [] });
|
||||
await expect(guard.canActivate(contextWith())).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('allows the edit when the last run has no last_status', async () => {
|
||||
const { guard } = buildGuard({ pipeline: batchPipeline, runs: [{}] });
|
||||
await expect(guard.canActivate(contextWith())).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('allows the edit when the pipeline is not running', async () => {
|
||||
const { guard } = buildGuard({
|
||||
pipeline: batchPipeline,
|
||||
runs: [{ last_status: 'SUCCEEDED' }],
|
||||
});
|
||||
await expect(guard.canActivate(contextWith())).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('blocks with the is-running message when the pipeline is running', async () => {
|
||||
const { guard } = buildGuard({
|
||||
pipeline: batchPipeline,
|
||||
runs: [{ last_status: 'RUNNING' }],
|
||||
});
|
||||
await expect(guard.canActivate(contextWith())).rejects.toThrow(
|
||||
'Pipeline is running, cannot update input now',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a pipeline with no jobs as batch and checks its runs', async () => {
|
||||
const { guard, proxy } = buildGuard({ pipeline: { jobs: [] }, runs: [] });
|
||||
await expect(guard.canActivate(contextWith())).resolves.toBe(true);
|
||||
expect(proxy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not double-wrap the is-running BadRequestException', async () => {
|
||||
const { guard } = buildGuard({
|
||||
pipeline: batchPipeline,
|
||||
runs: [{ last_status: 'running' }],
|
||||
});
|
||||
await expect(guard.canActivate(contextWith())).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(guard.canActivate(contextWith())).rejects.not.toThrow(
|
||||
/Error checking pipeline status/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failures', () => {
|
||||
it('fails closed when the pipeline read fails', async () => {
|
||||
const { guard } = buildGuard({ pipeline: new Error('platform down') });
|
||||
await expect(guard.canActivate(contextWith())).rejects.toThrow(
|
||||
'Error checking pipeline status: platform down',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when the run-history read fails', async () => {
|
||||
const { guard } = buildGuard({
|
||||
pipeline: batchPipeline,
|
||||
runs: new Error('runs down'),
|
||||
});
|
||||
await expect(guard.canActivate(contextWith())).rejects.toThrow(
|
||||
'Error checking pipeline status: runs down',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ClientGrpc } from '@nestjs/microservices';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
|
||||
import {
|
||||
ReadService,
|
||||
ProtoServices,
|
||||
} from '@dadosfera/protospack-v2/dist/lib/PipelineV2';
|
||||
import { PipelinesClientConfiguration } from 'src/modules/pipelinesV2/pipelines-client';
|
||||
import { PlatformApiService } from 'src/modules/platform-api/platform-api.service';
|
||||
import DadosferaLogger from '@dadosfera/dadosfera-logs';
|
||||
import { isCdcPipeline } from 'src/utils/cdc';
|
||||
|
||||
@Injectable()
|
||||
export class PipelineExecutionGuard implements CanActivate {
|
||||
logger: DadosferaLogger;
|
||||
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
private readonly platformApiService: PlatformApiService,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
this.logger.info(
|
||||
'PipelineExecutionGuard: Checking if pipeline can be executed...',
|
||||
);
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const pipelineId = request.params.pipelineId;
|
||||
const user = request.user;
|
||||
const idRegex = /[^0-9a-zA-Z_$]+/g;
|
||||
const convertedId = pipelineId.replace(idRegex, '_');
|
||||
|
||||
// Decide by the pipeline's explicit type (platform job
|
||||
// `input.connector`), never by the absence of run history.
|
||||
const pipeline = await this.platformApiService.proxy(
|
||||
'GET',
|
||||
`/pipeline/${convertedId}`,
|
||||
user,
|
||||
);
|
||||
|
||||
if (isCdcPipeline(pipeline)) {
|
||||
// CDC pipelines have no batch runs; the platform-api gates connector
|
||||
// edits itself (require_pipeline_running on add/remove tables).
|
||||
this.logger.info('PipelineExecutionGuard: CDC pipeline, no run to block on');
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = await this.platformApiService.proxy(
|
||||
'GET',
|
||||
`/pipeline/${convertedId}/pipeline_run`,
|
||||
user,
|
||||
);
|
||||
|
||||
const currentStatus = status?.[status.length - 1];
|
||||
|
||||
this.logger.info('Pipeline current status response:' + JSON.stringify(currentStatus));
|
||||
|
||||
// Batch pipeline that never ran yet: nothing can be executing.
|
||||
if (!currentStatus?.last_status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStatus.last_status.toLowerCase() === 'running') {
|
||||
this.logger.error('Pipeline is running, cannot update input now');
|
||||
throw new BadRequestException('Pipeline is running, cannot update input now');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Preserve the deliberate is-running rejection; only wrap genuine
|
||||
// status-check failures (fail closed on those for a destructive gate).
|
||||
if (error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error('Error in PipelineExecutionGuard: ' + error.message);
|
||||
throw new BadRequestException('Error checking pipeline status: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-25
@@ -18,41 +18,21 @@ async function bootstrap() {
|
||||
});
|
||||
const logger = new DadosferaLogger();
|
||||
|
||||
const corsOrigins = [];
|
||||
|
||||
if (process.env.ENV === 'local') {
|
||||
corsOrigins.push('http://localhost:4200');
|
||||
} else {
|
||||
corsOrigins.push(
|
||||
'https://app.stg.dadosfera.ai',
|
||||
'https://app.dadosfera.ai',
|
||||
'https://private-frontend.stg.dadosfera.ai',
|
||||
'https://unimed.dadosfera.ai',
|
||||
'https://boston-scientific.dadosfera.ai',
|
||||
'https://plataforma.dadosfera.ai'
|
||||
);
|
||||
}
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger,
|
||||
cors: {
|
||||
origin: corsOrigins,
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204,
|
||||
credentials: true,
|
||||
credentials: true
|
||||
},
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cookieParser(process.env.COOKIE_SECRET));
|
||||
|
||||
if (process.env.ENV !== 'local') {
|
||||
if (process.env.ENV === 'prd') {
|
||||
app.use('/catalog/register-dataset', json({ limit: '10mb' }));
|
||||
app.use(
|
||||
'/catalog/register-dataset',
|
||||
urlencoded({ extended: true, limit: '10mb' }),
|
||||
);
|
||||
app.use('/catalog/register-dataset', urlencoded({ extended: true, limit: '10mb' }));
|
||||
}
|
||||
|
||||
configureSwagger(app);
|
||||
@@ -111,4 +91,3 @@ function configureSwagger(app: INestApplication) {
|
||||
);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Body, Put, Get, NotFoundException} from '@nestjs/common';
|
||||
import { Controller, Post, Body, Put, Get} from '@nestjs/common';
|
||||
import { AssignService } from './assign.service';
|
||||
import { CreateAssignDto } from './dto/create-assign.dto';
|
||||
import { Authenticated, RequireModule, RequireSomePermission } from 'src/decorators/authentication.decorator';
|
||||
@@ -28,10 +28,6 @@ export class AssignController {
|
||||
@RequireModule(DADOSFERA_MODULES_KEYS.EMBED_ASSIGNED)
|
||||
async get(@User() user: RequestUser) {
|
||||
const metadata = PackTheMetadata(user);
|
||||
try {
|
||||
return await this.assignService.get(metadata);
|
||||
} catch (error) {
|
||||
throw new NotFoundException(error.message)
|
||||
}
|
||||
return await this.assignService.get(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Req,
|
||||
Param,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiHeaders,
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
RequireAllPermissions,
|
||||
} from 'src/decorators/authentication.decorator';
|
||||
import { AuthClientService } from './auth.service';
|
||||
import { UserDTO } from './dtos/login';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { GrpcToHttpExceptionFilter } from '../../error/grpc-to-http-exception.filter';
|
||||
import { RequestUser, User } from 'src/decorators/user.decorator';
|
||||
@@ -56,7 +54,7 @@ import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { LanguageEnum } from 'src/utils/languages.enum';
|
||||
import { Language } from 'src/decorators/language.decorator';
|
||||
import { ApiInternalOnlyEndpoint } from 'src/decorators/swagger.decorator';
|
||||
import { ApiKeyService } from 'src/modules/api-key/api-key.service';
|
||||
import { Cookie } from 'express-session';
|
||||
|
||||
type CookiesValues = {
|
||||
accessToken?: string;
|
||||
@@ -76,7 +74,6 @@ export class AuthController {
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
private authClient: AuthClientService,
|
||||
private apiKeyService: ApiKeyService,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
|
||||
@@ -106,13 +103,14 @@ export class AuthController {
|
||||
const data = await this.authClient.signIn({ username, password, totp }, metadata);
|
||||
|
||||
if (data.tokens) {
|
||||
this.authClient.writeAuthSession(res, {
|
||||
this.addTokenInCookie(res, {
|
||||
accessToken: data.tokens.accessToken,
|
||||
refreshToken: data.tokens.refreshToken,
|
||||
userId: data.user.id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return res.send(data);
|
||||
} catch (error) {
|
||||
this.logger.error('/auth - SignIn - ERROR', error);
|
||||
@@ -129,8 +127,27 @@ export class AuthController {
|
||||
) {
|
||||
try {
|
||||
this.logger.info('/auth - SignOut');
|
||||
|
||||
this.authClient.cleanUpAuthSession(res);
|
||||
const exp = 1000 * 60 * 3;
|
||||
|
||||
res.cookie('ddf-auth', '', {
|
||||
domain: 'dadosfera.local',
|
||||
maxAge: Date.now() - exp,
|
||||
expires: new Date(),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
|
||||
res.cookie('ddf-refresh-auth', '', {
|
||||
domain: 'dadosfera.local',
|
||||
maxAge: Date.now() - exp,
|
||||
expires: new Date(),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
|
||||
this.logger.info('Clean cookie sessions');
|
||||
|
||||
return res.send();
|
||||
} catch (error) {
|
||||
@@ -159,9 +176,8 @@ export class AuthController {
|
||||
|
||||
const data = await this.authClient.refreshAccessToken({ refreshToken, userId }, metadata);
|
||||
|
||||
this.authClient.writeAuthSession(res, {
|
||||
this.addTokenInCookie(res, {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
userId
|
||||
});
|
||||
|
||||
@@ -193,14 +209,13 @@ export class AuthController {
|
||||
) {
|
||||
this.logger.info('/auth - change-password');
|
||||
|
||||
const { oldPassword, newPassword, totpCode } = body;
|
||||
const { oldPassword, newPassword } = body;
|
||||
const { authorization: accessToken } = headers;
|
||||
|
||||
return this.authClient.changePassword({
|
||||
accessToken,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
totpCode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,8 +233,7 @@ export class AuthController {
|
||||
|
||||
const { username } = body;
|
||||
|
||||
await this.authClient.resetPassword({ username }, metadata);
|
||||
return { authProvider: process.env.AUTH_PROVIDER || 'cognito' };
|
||||
return this.authClient.resetPassword({ username }, metadata);
|
||||
}
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@@ -479,58 +493,114 @@ export class AuthController {
|
||||
@Get('me')
|
||||
async getMe(@Req() req: Request, @Res() res: Response) {
|
||||
this.logger.info('GET /auth/me ')
|
||||
this.logger.info(JSON.stringify(req.headers));
|
||||
|
||||
// Check for API key header first
|
||||
const apiKey = req.get('X-Api-key');
|
||||
if (apiKey) {
|
||||
this.logger.info('Authenticating via X-Api-key header');
|
||||
const { api_key } = await this.apiKeyService.get(apiKey);
|
||||
|
||||
const userDto: UserDTO = {
|
||||
id: api_key.user_id,
|
||||
name: api_key.username,
|
||||
email: api_key.username,
|
||||
customer: {
|
||||
id: api_key.customer_id,
|
||||
name: api_key.customer_name,
|
||||
tier: api_key.customer_tier,
|
||||
},
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
return res.status(200).json(userDto);
|
||||
}
|
||||
|
||||
// Get token and headers
|
||||
// Lê cookies
|
||||
const accessToken = req.cookies['ddf-auth'];
|
||||
const refreshToken = req.cookies['ddf-refresh-auth'];
|
||||
const userId = req.cookies['ddf-user-id'];
|
||||
const resourceHost = req.headers["x-original-url"] as string || "" ;
|
||||
|
||||
const hasUserSession = Boolean(accessToken) && Boolean(userId);
|
||||
this.logger.info('Has User Session: ' + hasUserSession);
|
||||
this.logger.info('Has cookie: ' + Boolean(accessToken))
|
||||
let payload: any;
|
||||
let userInfo: any = {};
|
||||
try {
|
||||
// Decodifica e valida o JWT de acesso
|
||||
const decoded: any = accessToken && jwt.decode(accessToken, { complete: true });
|
||||
if (!decoded) throw new Error('Invalid token')
|
||||
const { kid } = decoded.header;
|
||||
// Busca a chave pública
|
||||
const { keys } = await this.authClient.getPublicKeys();
|
||||
const pemValue = keys.find((k) => k.kid === kid)?.pem;
|
||||
if (!pemValue) throw new Error('Public key not found');
|
||||
jwt.verify(accessToken, pemValue);
|
||||
payload = decoded.payload;
|
||||
userInfo = {
|
||||
id: payload.user_id,
|
||||
name: payload.username,
|
||||
customer: {
|
||||
id: payload.customer_id,
|
||||
name: payload.customer_name,
|
||||
tier: payload.customer_tier,
|
||||
}
|
||||
};
|
||||
return res.status(200).json(userInfo);
|
||||
} catch (err) {
|
||||
this.logger.error(err.message);
|
||||
const refreshToken = req.cookies['ddf-refresh-auth'];
|
||||
|
||||
if (!hasUserSession) {
|
||||
throw new UnauthorizedException()
|
||||
this.logger.info('Token is invalid')
|
||||
this.logger.info('Has Refresh Token: '+ Boolean(refreshToken))
|
||||
// Se access token inválido, tenta refresh
|
||||
if (!refreshToken || !userId) {
|
||||
this.logger.error('Invalid refresh token or customer name');
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
try {
|
||||
// Chama refreshAccessToken
|
||||
const metadata = PackTheMetadata({
|
||||
});
|
||||
this.logger.info('Call Refresh Token')
|
||||
const data = await this.authClient.refreshAccessToken({ refreshToken, userId }, metadata);
|
||||
this.logger.info('Finish Refresh Token')
|
||||
// Retorna novo access token e dados mínimos
|
||||
this.addTokenInCookie(res, {
|
||||
accessToken: data.accessToken,
|
||||
userId
|
||||
});
|
||||
// Decodifica novo token
|
||||
const decoded: any = jwt.decode(data.accessToken, { complete: true });
|
||||
const payload = decoded.payload;
|
||||
userInfo = {
|
||||
id: payload.user_id,
|
||||
name: payload.username,
|
||||
customer: {
|
||||
id: payload.customer_id,
|
||||
name: payload.customer_name,
|
||||
tier: payload.customer_tier,
|
||||
}
|
||||
};
|
||||
return res.status(200).json(userInfo);
|
||||
} catch (refreshErr) {
|
||||
this.logger.error(refreshErr)
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addTokenInCookie(res: Response, data: CookiesValues) {
|
||||
let exp = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
if (data.accessToken) {
|
||||
const { exp: expiration } = jwt.decode(data.accessToken) as JwtPayload;
|
||||
exp = (expiration - 30) * 1000; // exp em segundos, maxAge em ms
|
||||
|
||||
this.logger.info('Set Cookie ddf-auth')
|
||||
res.cookie('ddf-auth', data.accessToken, {
|
||||
domain: 'stg.dadosfera.ai',
|
||||
maxAge: exp,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userDto = await this.authClient.validateUserSession(accessToken, resourceHost);
|
||||
return res.status(200).json(userDto);
|
||||
} catch (error) {
|
||||
if (data.refreshToken) {
|
||||
this.logger.info('Set Cookie ddf-refresh-auth')
|
||||
res.cookie('ddf-refresh-auth', data.refreshToken, {
|
||||
domain: 'stg.dadosfera.ai',
|
||||
maxAge: exp,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
this.logger.error('Invalid refresh token or customer name');
|
||||
throw new UnauthorizedException("Invalid refresh token or customer name");
|
||||
};
|
||||
|
||||
const {
|
||||
authSession,
|
||||
user
|
||||
} = await this.authClient.refreshUserSession(refreshToken, userId, resourceHost);
|
||||
this.authClient.writeAuthSession(res, authSession);
|
||||
return res.status(200).json(user);
|
||||
if (data.userId) {
|
||||
this.logger.info('Set Cookie ddf-refresh-auth')
|
||||
res.cookie('ddf-user-id', data.userId, {
|
||||
domain: 'stg.dadosfera.ai',
|
||||
maxAge: exp,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,10 @@ import { AuthClientService } from './auth.service';
|
||||
import { DucClient } from '../duc/client.config';
|
||||
import { GoogleLoginStrategy } from './passport-strategies/google-strategy';
|
||||
import { getOauthSecrets } from 'src/utils/OauthSecrets';
|
||||
import { ApiKeyModule } from '../api-key/api-key.module';
|
||||
const client = new DucClient();
|
||||
|
||||
@Module({
|
||||
imports: [ClientsModule.register([client.providerOptions]), ApiKeyModule],
|
||||
imports: [ClientsModule.register([client.providerOptions])],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthClientService,
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import {
|
||||
OnModuleInit,
|
||||
Inject,
|
||||
Injectable,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { OnModuleInit, Inject, Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { ClientGrpc } from '@nestjs/microservices';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { ProtoServices } from '@dadosfera/protospack-v2/dist/lib/Duc';
|
||||
import {
|
||||
AuthProtoService as AuthServiceInterface,
|
||||
UsersProtoService,
|
||||
} from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service';
|
||||
import { AuthProtoService as AuthServiceInterface, IdentityProviderProtoService } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service';
|
||||
import {
|
||||
AuthSnowflakeSignInRequest,
|
||||
AuthSignInRequest,
|
||||
@@ -31,24 +21,18 @@ import {
|
||||
} from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages';
|
||||
import { DucClient } from '../duc/client.config';
|
||||
import { Metadata } from '@grpc/grpc-js';
|
||||
import { BulkEditResponse, UserDTO } from './dtos/login';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
|
||||
import { Request, Response } from 'express';
|
||||
import { BulkEditResponse } from './dtos/login';
|
||||
|
||||
type AuthSession = {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthClientService implements OnModuleInit {
|
||||
|
||||
|
||||
logger: DadosferaLogger;
|
||||
|
||||
private authService: AuthServiceInterface;
|
||||
private userService: UsersProtoService;
|
||||
|
||||
private authService: AuthServiceInterface;
|
||||
private identityProviderService: IdentityProviderProtoService;
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
@@ -62,8 +46,8 @@ export class AuthClientService implements OnModuleInit {
|
||||
ProtoServices.AuthProtoService,
|
||||
);
|
||||
|
||||
this.userService = this.grpcClient.getService<UsersProtoService>(
|
||||
ProtoServices.UsersProtoService,
|
||||
this.identityProviderService = this.grpcClient.getService<IdentityProviderProtoService>(
|
||||
ProtoServices.IdentityProviderProtoService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,11 +63,11 @@ export class AuthClientService implements OnModuleInit {
|
||||
return lastValueFrom(this.authService.AuthSnowflakeSignIn(input));
|
||||
}
|
||||
|
||||
checkDedicatedProxy({ customer }: AuthSignInResponse) {
|
||||
checkDedicatedProxy({
|
||||
customer
|
||||
}: AuthSignInResponse) {
|
||||
const DEDICATED_PROXY = process.env.DEDICATED_PROXY || '';
|
||||
this.logger.info(
|
||||
'SignIn - Setting customer ID for dedicated proxy: ' + DEDICATED_PROXY,
|
||||
);
|
||||
this.logger.info('SignIn - Setting customer ID for dedicated proxy: ' + DEDICATED_PROXY);
|
||||
this.logger.info('Customer ID: ' + customer.id);
|
||||
|
||||
if (DEDICATED_PROXY !== '' && DEDICATED_PROXY !== customer.id) {
|
||||
@@ -91,9 +75,7 @@ export class AuthClientService implements OnModuleInit {
|
||||
}
|
||||
|
||||
// Bloquear o customer de acesso o maestro publico
|
||||
this.logger.info(
|
||||
'Check if customer have network policy: ' + customer.modules,
|
||||
);
|
||||
this.logger.info('Check if customer have network policy: ' + customer.modules);
|
||||
const hasNetworkPolicyModule = customer.modules.includes('network-policy');
|
||||
if (hasNetworkPolicyModule && DEDICATED_PROXY === '') {
|
||||
throw new ForbiddenException();
|
||||
@@ -112,6 +94,7 @@ export class AuthClientService implements OnModuleInit {
|
||||
result = await lastValueFrom(
|
||||
this.authService.AuthSignIn({ username, password, totp }, metadata),
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('SignIn - Error during sign-in');
|
||||
this.logger.error(error);
|
||||
@@ -122,7 +105,7 @@ export class AuthClientService implements OnModuleInit {
|
||||
this.checkDedicatedProxy(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
async refreshAccessToken(
|
||||
@@ -132,10 +115,7 @@ export class AuthClientService implements OnModuleInit {
|
||||
this.logger.info('RefreshAccessToken');
|
||||
|
||||
return lastValueFrom(
|
||||
this.authService.AuthRefreshAccessToken(
|
||||
{ refreshToken, userId },
|
||||
metadata,
|
||||
),
|
||||
this.authService.AuthRefreshAccessToken({ refreshToken, userId }, metadata),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +123,6 @@ export class AuthClientService implements OnModuleInit {
|
||||
accessToken,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
totpCode,
|
||||
}: AuthChangePasswordRequest) {
|
||||
this.logger.info('ChangePassword');
|
||||
|
||||
@@ -152,7 +131,6 @@ export class AuthClientService implements OnModuleInit {
|
||||
accessToken,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
totpCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -305,194 +283,4 @@ export class AuthClientService implements OnModuleInit {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async validateUserSession(accessToken: any, resourceHost: string) {
|
||||
const payload = await this.validateJwtToken(accessToken);
|
||||
|
||||
const userDto = await this.getUserfromPayload(payload);
|
||||
|
||||
this.validateResourceAccess(resourceHost, userDto);
|
||||
return userDto;
|
||||
}
|
||||
|
||||
public async refreshUserSession(
|
||||
refreshToken: string,
|
||||
userId: string,
|
||||
originHeader: string,
|
||||
): Promise<{
|
||||
user: UserDTO;
|
||||
authSession: AuthSession;
|
||||
}> {
|
||||
const metadata = PackTheMetadata({});
|
||||
|
||||
this.logger.info('Call Refresh Token');
|
||||
const refreshCredentials = await this.refreshAccessToken(
|
||||
{ refreshToken, userId },
|
||||
metadata,
|
||||
);
|
||||
this.logger.info('Finish Refresh Token');
|
||||
|
||||
const userDto = await this.validateUserSession(
|
||||
refreshCredentials.accessToken,
|
||||
originHeader,
|
||||
);
|
||||
return {
|
||||
user: userDto,
|
||||
authSession: {
|
||||
accessToken: refreshCredentials.accessToken,
|
||||
refreshToken: refreshCredentials.refreshToken,
|
||||
userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public writeAuthSession(res: Response, data: AuthSession) {
|
||||
let exp = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
if (data.accessToken) {
|
||||
const { exp: expiration } = jwt.decode(data.accessToken) as JwtPayload;
|
||||
exp = (expiration - 30) * 1000; // exp em segundos, maxAge em ms
|
||||
|
||||
this.logger.info('Set Cookie ddf-auth');
|
||||
res.cookie('ddf-auth', data.accessToken, {
|
||||
domain: '.dadosfera.ai',
|
||||
maxAge: exp,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
}
|
||||
|
||||
if (data.refreshToken) {
|
||||
this.logger.info('Set Cookie ddf-refresh-auth');
|
||||
res.cookie('ddf-refresh-auth', data.refreshToken, {
|
||||
domain: '.dadosfera.ai',
|
||||
maxAge: exp,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
}
|
||||
|
||||
if (data.userId) {
|
||||
this.logger.info('Set Cookie ddf-refresh-auth');
|
||||
res.cookie('ddf-user-id', data.userId, {
|
||||
domain: '.dadosfera.ai',
|
||||
maxAge: exp,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public cleanUpAuthSession(res: Response) {
|
||||
const exp = 1000 * 60 * 3;
|
||||
|
||||
res.cookie('ddf-auth', '', {
|
||||
domain: 'dadosfera.ai',
|
||||
maxAge: Date.now() - exp,
|
||||
expires: new Date(),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
|
||||
res.cookie('ddf-refresh-auth', '', {
|
||||
domain: 'dadosfera.ai',
|
||||
maxAge: Date.now() - exp,
|
||||
expires: new Date(),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none', // Necessário para cookies em requisições cross-site
|
||||
});
|
||||
|
||||
this.logger.info('Clean cookie sessions');
|
||||
}
|
||||
|
||||
private async validateJwtToken(token: string) {
|
||||
const decoded: any = token && jwt.decode(token, { complete: true });
|
||||
if (!decoded) throw new Error('Invalid token');
|
||||
|
||||
const { kid } = decoded.header;
|
||||
// Busca a chave pública
|
||||
const { keys } = await this.getPublicKeys();
|
||||
const pemValue = keys.find((k) => k.kid === kid)?.pem;
|
||||
if (!pemValue) throw new Error('Public key not found');
|
||||
jwt.verify(token, pemValue);
|
||||
|
||||
return decoded.payload;
|
||||
}
|
||||
|
||||
private async getUserfromPayload(payload: JwtPayload): Promise<UserDTO> {
|
||||
this.logger.info('getUser');
|
||||
|
||||
const metadata = PackTheMetadata({
|
||||
customer_id: payload.customer_id,
|
||||
});
|
||||
|
||||
const { user } = await lastValueFrom(
|
||||
this.userService.UserFindOneById({ id: payload.user_id }, metadata),
|
||||
);
|
||||
|
||||
const userDto: UserDTO = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
jobTitle: user?.jobTitle || null,
|
||||
department: user?.department || null,
|
||||
hierarchy: user?.hierarchy || null,
|
||||
customer: {
|
||||
id: payload.customer_id,
|
||||
name: payload.customer_name,
|
||||
tier: payload.customer_tier,
|
||||
},
|
||||
// Raw permission seqids from the JWT. Consumers own the seqid->meaning
|
||||
// mapping (e.g. Orchest's auth-server); Maestro reports them as-is.
|
||||
permissions: payload.permissions ?? [],
|
||||
};
|
||||
|
||||
return userDto;
|
||||
}
|
||||
|
||||
private validateResourceAccess(host: string, user: UserDTO) {
|
||||
this.logger.info(
|
||||
"Validate whether the source URL is a resource belonging to the user's client",
|
||||
);
|
||||
this.logger.info('Host: ' + host);
|
||||
this.logger.info('Customer: ' + user.customer.name);
|
||||
|
||||
const hostParts = host.split('.');
|
||||
const domain = hostParts[0];
|
||||
const isResouceStg = hostParts[1] === 'stg';
|
||||
|
||||
const notFoundCustomerInDomain = !domain.includes('-')
|
||||
|
||||
if (notFoundCustomerInDomain) {
|
||||
this.logger.info(`Not found Customer Name in domain`);
|
||||
return;
|
||||
}
|
||||
|
||||
const domainParts = domain.split('-');
|
||||
|
||||
const customerInDomain = domainParts[domainParts.length - 1];
|
||||
|
||||
if (isResouceStg && process.env.ENV !== 'stg') {
|
||||
this.logger.error(`Customer ${user.customer.name} cannot access ${host}`);
|
||||
throw new HttpException(
|
||||
`Customer ${user.customer.name} cannot access ${host}`,
|
||||
HttpStatus.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (customerInDomain != user.customer.name) {
|
||||
this.logger.error(`Customer ${user.customer.name} cannot access ${host}`);
|
||||
throw new HttpException(
|
||||
`Customer ${user.customer.name} cannot access ${host}`,
|
||||
HttpStatus.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,18 +140,3 @@ export interface BulkEditResponse {
|
||||
successfulUsers: string[];
|
||||
failedUsers: string[];
|
||||
}
|
||||
|
||||
export type UserDTO = {
|
||||
id: string,
|
||||
name: string,
|
||||
email: string,
|
||||
jobTitle?: string,
|
||||
department?: string,
|
||||
hierarchy?: string,
|
||||
customer: {
|
||||
id: string,
|
||||
name: string,
|
||||
tier: string,
|
||||
},
|
||||
permissions: number[],
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
@@ -18,12 +17,14 @@ import {
|
||||
HttpStatus,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ValidationPipe } from '../../pipes/object-validation.pipe';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiHeaders,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
Authenticated,
|
||||
@@ -48,11 +49,9 @@ import {
|
||||
IMakeAComment,
|
||||
IOneDataAsset,
|
||||
IPreviewResponse,
|
||||
IUpdateCertificationStatusRequest,
|
||||
IUpdateDataRequest,
|
||||
TriggerCatalogReq,
|
||||
TriggerCatalogRes,
|
||||
UpdateColumnsMetadataRequest,
|
||||
} from './dtos';
|
||||
import { GrpcToHttpExceptionFilter } from 'src/error/grpc-to-http-exception.filter';
|
||||
import { Language } from 'src/decorators/language.decorator';
|
||||
@@ -87,18 +86,11 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async searchCatalog(
|
||||
@User() user: RequestUser,
|
||||
@Query() query: ICatalogAllRequest,
|
||||
): Promise<ICatalogAllResponse> {
|
||||
const { user_id, customer_name, customer_id, username, permissions } = user;
|
||||
this.logger.info(`/catalog - searchCatalog`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const is_data_manager = permissions.includes(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER.seqid,
|
||||
@@ -129,19 +121,12 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async dowloadAsserts(
|
||||
@User() user: RequestUser,
|
||||
@Query() query: ICatalogAllRequest,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const { user_id, customer_name, customer_id, username, permissions } = user;
|
||||
this.logger.info(`/catalog/download - searchCatalog`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const is_data_manager = permissions.includes(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER.seqid,
|
||||
@@ -175,16 +160,9 @@ export class CatalogController {
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Get('data-asset')
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async findByPipelineAndObject(@User() user: RequestUser, @Query() query) {
|
||||
const { username, user_id, customer_id, customer_name, permissions } = user;
|
||||
const { pipeline, object } = query;
|
||||
this.logger.info(`/catalog - ON GET DATA ASSET BY PIPELINE AND OBJECT`, {
|
||||
username,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
if (!pipeline || !object) {
|
||||
throw new BadRequestException('Query params not provided');
|
||||
@@ -236,14 +214,7 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async findAllTags(@Body() body) {
|
||||
this.logger.info(`/catalog - ON FIND ALL TAGS ROUTE`, {
|
||||
user: body.info.user_id,
|
||||
customer: body.info.customer,
|
||||
});
|
||||
|
||||
const { user_id, customer, customer_id } = body.info;
|
||||
const metadata = PackTheMetadata({
|
||||
@@ -257,59 +228,11 @@ export class CatalogController {
|
||||
return res;
|
||||
}
|
||||
|
||||
@Get('schemas')
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
async findSchemas(@User() user: RequestUser) {
|
||||
const { username, user_id, customer_id, customer_name } = user;
|
||||
this.logger.info(`/catalog - ON FIND SCHEMAS ROUTE`, {
|
||||
username,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const metadata = PackTheMetadata({
|
||||
username,
|
||||
user_id,
|
||||
customer_id,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await this.catalogService.findSchemas(metadata);
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new HttpException(error.message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('custom-properties')
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
async getCustomPropertyDefinitions(@User() user: RequestUser) {
|
||||
const { customer_id, customer_name, user_id, username } = user;
|
||||
const metadata = PackTheMetadata({
|
||||
customer_id,
|
||||
customer_name,
|
||||
user_id,
|
||||
username,
|
||||
});
|
||||
|
||||
return this.catalogService.getCustomPropertyDefinitions(metadata);
|
||||
}
|
||||
|
||||
@Get('data-asset/:id')
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async getDataAsset(
|
||||
@User() user: RequestUser,
|
||||
@Param('id') id: string,
|
||||
@@ -317,10 +240,6 @@ export class CatalogController {
|
||||
) {
|
||||
const { username, user_id, customer_id, customer_name, permissions } = user;
|
||||
|
||||
this.logger.info(`GET /data-asset/${id}`, {
|
||||
username,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const is_data_manager = permissions.includes(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER.seqid,
|
||||
@@ -373,10 +292,6 @@ export class CatalogController {
|
||||
) {
|
||||
const { username, user_id, customer_id, customer_name, permissions } = user;
|
||||
|
||||
this.logger.info(`/catalog - ON GET ONE DASHBOARD METABASE ROUTE`, {
|
||||
username,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const is_data_manager = permissions.includes(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER.seqid,
|
||||
@@ -421,9 +336,6 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async getDataAssetColumnsMetadata(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@@ -431,10 +343,6 @@ export class CatalogController {
|
||||
): Promise<IColumnsMetadataResponse> {
|
||||
const { customer_name, customer_id, user_id, username } = user;
|
||||
|
||||
this.logger.info(`/catalog - columns-metadata`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const metadata = PackTheMetadata({
|
||||
customer_name,
|
||||
@@ -450,50 +358,11 @@ export class CatalogController {
|
||||
return { columns_metadata };
|
||||
}
|
||||
|
||||
@Patch('data-asset/:id/columns-metadata')
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
async updateColumnsMetadata(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@Param('id') id: string,
|
||||
@Body(new ValidationPipe()) body: UpdateColumnsMetadataRequest,
|
||||
): Promise<{ success: boolean }> {
|
||||
const { customer_name, customer_id, user_id, username } = user;
|
||||
|
||||
this.logger.info(`/catalog - update columns metadata`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
columns_count: body.columns.length,
|
||||
});
|
||||
|
||||
const metadata = PackTheMetadata({
|
||||
customer_name,
|
||||
customer_id,
|
||||
user_id,
|
||||
username,
|
||||
language,
|
||||
});
|
||||
|
||||
await this.catalogService.updateColumnsDescriptions(
|
||||
id,
|
||||
body.columns,
|
||||
metadata,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('data-asset/:id/preview')
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async getDataAssetPreview(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@@ -501,10 +370,6 @@ export class CatalogController {
|
||||
): Promise<IPreviewResponse> {
|
||||
const { customer_name, customer_id, user_id, username, customer_modules } = user;
|
||||
|
||||
this.logger.info(`/catalog - ON GET DATA DOCS ROUTE`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const metadata = PackTheMetadata({
|
||||
customer_name,
|
||||
@@ -525,33 +390,36 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async getDataAssetDocs(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@Param('id') id: string,
|
||||
@Query('asset_type') asset_type: string,
|
||||
): Promise<IDocsResponse> {
|
||||
const { customer_name, customer_id, user_id, username } = user;
|
||||
try {
|
||||
const { customer_name, customer_id, user_id, username } = user;
|
||||
|
||||
this.logger.info(`/catalog - ON GET DATA DOCS ROUTE`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
});
|
||||
this.logger.info(`/catalog - ON GET DATA DOCS ROUTE`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
id,
|
||||
});
|
||||
|
||||
const metadata = PackTheMetadata({
|
||||
customer_name,
|
||||
customer_id,
|
||||
user_id,
|
||||
username,
|
||||
language,
|
||||
});
|
||||
const metadata = PackTheMetadata({
|
||||
customer_name,
|
||||
customer_id,
|
||||
user_id,
|
||||
username,
|
||||
language,
|
||||
});
|
||||
|
||||
const docs = await this.catalogService.getDataDocs(id, asset_type, metadata);
|
||||
const docs = await this.catalogService.getDataDocs(id, metadata);
|
||||
|
||||
return { docs };
|
||||
return { docs };
|
||||
} catch (error) {
|
||||
this.logger.error(`Error in getDataAssetDocs for id ${id}: ${error.message}`);
|
||||
this.logger.error(`Error details: ${JSON.stringify(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Put('data-asset/:id')
|
||||
@@ -559,9 +427,6 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async updateDataAsset(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@@ -577,8 +442,6 @@ export class CatalogController {
|
||||
language,
|
||||
});
|
||||
|
||||
delete (body as any).certification_status;
|
||||
|
||||
const result = await this.catalogService.updateOneDataAsset({
|
||||
body,
|
||||
data_asset_id,
|
||||
@@ -592,84 +455,34 @@ export class CatalogController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('data-asset/:id/certification-status')
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.CERTIFY,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async updateDataAssetCertificationStatus(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@Param('id') data_asset_id: string,
|
||||
@Body(new ValidationPipe()) body: IUpdateCertificationStatusRequest,
|
||||
): Promise<IUpdateCertificationStatusRequest> {
|
||||
const { customer_id, customer_name, user_id, username } = user;
|
||||
const metadata = PackTheMetadata({
|
||||
customer_id,
|
||||
customer_name,
|
||||
user_id,
|
||||
username,
|
||||
language,
|
||||
});
|
||||
|
||||
return this.catalogService.updateCertificationStatus({
|
||||
body,
|
||||
data_asset_id,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('data-asset/:id/docs')
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async manageDataAssetDocs(
|
||||
@User() user: RequestUser,
|
||||
@Headers() headers,
|
||||
@Param('id') table_id: string,
|
||||
@Body('docs') docs: string,
|
||||
@Query('asset_type') asset_type: string,
|
||||
) {
|
||||
const { user_id, customer_name, customer_id, username } = user;
|
||||
|
||||
const metadata = PackTheMetadata({
|
||||
customer_id,
|
||||
customer_name,
|
||||
user_id,
|
||||
username,
|
||||
});
|
||||
const { user_id, customer_name } = user;
|
||||
|
||||
this.logger.info(`/catalog - ON POST DATA DOCS ROUTE`, {
|
||||
user_id,
|
||||
customer_name,
|
||||
});
|
||||
|
||||
const body = {
|
||||
const res = await this.catalogService.createDataDocs({
|
||||
table_id,
|
||||
docs,
|
||||
asset_type,
|
||||
info: {
|
||||
customer: customer_name,
|
||||
},
|
||||
}
|
||||
|
||||
const res = await this.catalogService.createDataDocs(body, metadata);
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Put('data-asset/:id/manage-permissions')
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async manageDataAssetPermissions(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
@@ -692,9 +505,6 @@ export class CatalogController {
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Put('data-asset/:id/revoke-permissions')
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async revokeDataAssetPermissions(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
@@ -720,9 +530,6 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.CREATE,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async createDataAsset(
|
||||
@User() user: RequestUser,
|
||||
@Body() body: ICreateDataAsset,
|
||||
@@ -747,9 +554,6 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async commentOnDataAsset(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
@@ -776,9 +580,6 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DELETE,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async deleteDataAsset(@Param('id') id: string, @User() user: RequestUser) {
|
||||
const { customer_id, customer_name, user_id, username } = user;
|
||||
const metadata = PackTheMetadata({
|
||||
@@ -800,9 +601,6 @@ export class CatalogController {
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async deleteComment(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
@@ -956,9 +754,6 @@ export class CatalogController {
|
||||
|
||||
@Get('nimbus-dashboards')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.CATALOG
|
||||
)
|
||||
async getNimbusDashboards(
|
||||
@User() user: RequestUser,
|
||||
@Body() body: GetNimbusDashboardsRequest,
|
||||
@@ -1111,4 +906,88 @@ export class CatalogController {
|
||||
this.logger.error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/data-asset/:nimbus_id/docs/ai')
|
||||
@ApiOperation({
|
||||
summary: 'Save documentation for data asset',
|
||||
description: 'Saves documentation content for a data asset',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'nimbus_id',
|
||||
description: 'Nimbus ID of the data asset',
|
||||
type: 'string',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Documentation saved successfully',
|
||||
})
|
||||
async saveDocumentation(
|
||||
@Param('nimbus_id') nimbusId: string,
|
||||
@Body() body: { docs: string },
|
||||
@User() user: RequestUser,
|
||||
) {
|
||||
|
||||
const metadata = PackTheMetadata(user);
|
||||
|
||||
try {
|
||||
await this.catalogService.updateDataAssetDocumentation(nimbusId, body.docs, metadata);
|
||||
return {
|
||||
message: 'Documentation saved successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving documentation for ${nimbusId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/data-asset/:nimbus_id/docs/generate-ai')
|
||||
@ApiOperation({
|
||||
summary: 'Generate AI documentation for data asset',
|
||||
description: 'Generates comprehensive documentation for a data asset using AI (Autodrive)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'nimbus_id',
|
||||
description: 'Nimbus ID of the data asset',
|
||||
type: 'string',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'AI documentation generated successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
documentation: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Bad request - invalid nimbus_id or missing data',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'Data asset not found',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 500,
|
||||
description: 'Internal server error during AI generation',
|
||||
})
|
||||
async generateAiDocumentation(
|
||||
@Param('nimbus_id') dataAssetId: string,
|
||||
@User() user: RequestUser,
|
||||
) {
|
||||
const metadata = PackTheMetadata(user);
|
||||
|
||||
try {
|
||||
const result = await this.catalogService.generateAiDocumentation(dataAssetId, metadata, user);
|
||||
return {
|
||||
message: 'AI documentation generated successfully',
|
||||
documentation: result,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error generating AI documentation for ${dataAssetId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,20 @@ import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogClientConfiguration } from './catalog-client';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { PipelinesModule as OldPipelineModule } from 'src/modules/pipelines/pipelines.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { RolesModule } from '../roles/roles.module';
|
||||
import { CustomersModule } from '../customers/customers.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { MixpanelModule } from '../mixpanel/mixpanel.module';
|
||||
|
||||
const client = new CatalogClientConfiguration();
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClientsModule.register([client.providerOptions]),
|
||||
OldPipelineModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
CustomersModule,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Constantes relacionadas ao Autodrive
|
||||
*
|
||||
*/
|
||||
export const AUTODRIVE_CONSTANTS = {
|
||||
// URLs e endpoints (apenas do ENV)
|
||||
BASE_URL: process.env.BASE_URL_AUTODRIVE || process.env.AUTODRIVE_BASE_URL,
|
||||
|
||||
// Credenciais (apenas do ENV, sem fallback para segurança)
|
||||
USERNAME: process.env.AUTODRIVE_USERNAME,
|
||||
PASSWORD: process.env.AUTODRIVE_PASSWORD,
|
||||
|
||||
// Modelo padrão
|
||||
DEFAULT_MODEL: process.env.AUTODRIVE_MODEL || "gpt-4o",
|
||||
|
||||
// Timeouts (em milissegundos)
|
||||
ASK_TIMEOUT: 120000,
|
||||
ANSWER_TIMEOUT: 180000,
|
||||
|
||||
// Headers
|
||||
HEADERS: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Chaves geográficas para detecção de dados de localização
|
||||
*/
|
||||
export const GEOGRAPHIC_KEYS = [
|
||||
'country', 'countries', 'city', 'cities',
|
||||
'region', 'regions', 'location', 'state',
|
||||
'states', 'address'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Países comuns para detecção automática
|
||||
*/
|
||||
export const COMMON_COUNTRIES = [
|
||||
'brazil', 'brasil', 'usa', 'united states',
|
||||
'canada', 'mexico', 'argentina', 'chile',
|
||||
'colombia'
|
||||
] as const;
|
||||
@@ -1,13 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PickType } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CreateDataAssetRequest } from '@dadosfera/protospack-v2/dist/lib/Catalog/interfaces/messages';
|
||||
|
||||
export enum DataAssetShareType {
|
||||
@@ -15,12 +6,6 @@ export enum DataAssetShareType {
|
||||
public = 'public',
|
||||
private = 'private',
|
||||
}
|
||||
export enum CertificationStatus {
|
||||
draft = 'draft',
|
||||
in_review = 'in_review',
|
||||
approved = 'approved',
|
||||
deprecated = 'deprecated',
|
||||
}
|
||||
export enum OrderEnum {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
@@ -113,8 +98,6 @@ export class IDataAsset {
|
||||
embed?: EmbedObject;
|
||||
@ApiPropertyOptional({ enum: DataAssetShareType })
|
||||
share_type?: DataAssetShareType;
|
||||
@ApiPropertyOptional()
|
||||
docs?: string;
|
||||
}
|
||||
|
||||
export class IOneDataAsset {
|
||||
@@ -164,24 +147,6 @@ export class ICatalogAllRequest {
|
||||
description: 'Tipo de ordenação - `asc`: crescente; `desc`: decrescente ',
|
||||
})
|
||||
order?: OrderEnum;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'ID do usuário owner para filtrar data assets',
|
||||
example: 'user-id-1,user-id-2',
|
||||
})
|
||||
owner?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Data inicial para filtro de catálogo (formato: YYYY-MM-DD)',
|
||||
example: '2025-01-01',
|
||||
})
|
||||
catalog_date_from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Data final para filtro de catálogo (formato: YYYY-MM-DD)',
|
||||
example: '2025-12-31',
|
||||
})
|
||||
catalog_date_to?: string;
|
||||
}
|
||||
|
||||
export class ICatalogAllResponse {
|
||||
@@ -206,27 +171,6 @@ export class IData {
|
||||
day_opening: number;
|
||||
}
|
||||
|
||||
|
||||
export enum CustomPropertyType {
|
||||
TEXT = 'text',
|
||||
NUMBER = 'number',
|
||||
DATE = 'date',
|
||||
BOOLEAN = 'boolean',
|
||||
}
|
||||
|
||||
export class CustomPropertyDto {
|
||||
@ApiProperty()
|
||||
key: string;
|
||||
@ApiProperty()
|
||||
value: string;
|
||||
@ApiProperty({ enum: CustomPropertyType })
|
||||
type: CustomPropertyType;
|
||||
@ApiPropertyOptional()
|
||||
color?: string;
|
||||
@ApiPropertyOptional()
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
export class IUpdateDataRequest {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@@ -238,38 +182,7 @@ export class IUpdateDataRequest {
|
||||
embed: EmbedObject;
|
||||
@ApiPropertyOptional({ enum: DataAssetShareType })
|
||||
share_type?: DataAssetShareType;
|
||||
@ApiPropertyOptional()
|
||||
docs?: string;
|
||||
@ApiPropertyOptional({ type: [CustomPropertyDto] })
|
||||
custom_properties?: CustomPropertyDto[];
|
||||
}
|
||||
|
||||
export class IUpdateCertificationStatusRequest {
|
||||
@ApiProperty({ enum: CertificationStatus })
|
||||
@IsEnum(CertificationStatus)
|
||||
certification_status: CertificationStatus;
|
||||
}
|
||||
|
||||
export class ColumnDescriptionDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
column_name: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class UpdateColumnsMetadataRequest {
|
||||
@ApiProperty({ type: [ColumnDescriptionDto] })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ColumnDescriptionDto)
|
||||
columns: ColumnDescriptionDto[];
|
||||
}
|
||||
|
||||
export class ICreateDataAsset implements CreateDataAssetRequest {
|
||||
@ApiProperty()
|
||||
display_name: string;
|
||||
@@ -283,8 +196,6 @@ export class ICreateDataAsset implements CreateDataAssetRequest {
|
||||
location: string;
|
||||
@ApiPropertyOptional()
|
||||
embed: EmbedObject;
|
||||
@ApiPropertyOptional()
|
||||
docs: string;
|
||||
}
|
||||
|
||||
export class IPreview {
|
||||
@@ -417,10 +328,3 @@ export type AssetReporter = {
|
||||
created_at: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export type CreateDataDocsDTO = {
|
||||
table_id: string;
|
||||
docs: string;
|
||||
asset_type: string;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
|
||||
*/
|
||||
export const AI_DOCUMENTATION_PROMPT = `crie uma documentação em Portugues, Ingles e Espanhol seguindo essas instruções
|
||||
1. Persona: como profissional de governança e engenharia de dados
|
||||
2. Tarefa: ao receber as informações da tabela criar uma documentação com o seguinte escopo
|
||||
**A primeira linha do documento tem que conter a seguinte informação: ## Document languages: EN / BR / ES
|
||||
**A segunda linha tem que obrigatoriamente conter a escrita Table: nome da tabela
|
||||
**A terceira linha tem que obrigatoriamente conter a escrita Table Schema: nome do table schema
|
||||
**DIRETRIZ CRUCIAL DE CONSISTÊNCIA E COMPLETUDE DE SCHEMA:**
|
||||
**1. Fonte Exclusiva de Metadados:** O 'Table Schema' definido na linha acima é a ÚNICA fonte de verdade para o schema dos dados a serem documentados. TODAS as informações subsequentes, especialmente na seção 'Estrutura da Tabela' (incluindo a lista de colunas, seus nomes, tipos de dados, descrições e exemplos) DEVEM ser extraídas EXCLUSIVAMENTE de metadados que correspondem a ESTE 'Table Schema'. Se os dados de entrada que você recebeu contiverem informações para a mesma tabela ou colunas mas de schemas diferentes (ex: um schema 'bronze' e um 'silver'), você DEVE IGNORAR TOTALMENTE as informações dos schemas divergentes para esta tarefa de documentação e utilizar APENAS as do 'Table Schema' aqui especificado.
|
||||
**2. Listagem Completa de Colunas:** Sua principal tarefa na seção 'Estrutura da Tabela' é identificar e listar TODAS as colunas que pertencem ao 'Table Schema' especificado. Verifique nos dados de entrada fornecidos se há uma indicação explícita do número total de colunas para esta tabela neste schema (por exemplo, um campo como 'Num_columns' ou similar nos metadados da tabela). Você deve se esforçar para listar exatamente essa quantidade de colunas. Se essa contagem não estiver disponível, liste todas as colunas que você puder identificar como pertencentes exclusivamente a este 'Table Schema'. A completude em relação ao schema especificado é essencial.
|
||||
|
||||
**Depois de "Estrutura da tablea", incluir a mensagem "Este documento foi gerado por IA", traduzida corretamente para cada idioma.**
|
||||
**Obrigatoriamente:Após finalizar a versão em Inglês, começar a versão em Português** **Após finalizar a versão em Português, começar a versão em Espanhol** **Antes de começar cada versão, colocar um título como:** - \`## English Version\` (para inglês)
|
||||
- \`## Versão em Português\` (para português)
|
||||
- \`## Versión en Español\` (para espanhol)
|
||||
- Descrição: fornece uma visão geral do ativo de dados,
|
||||
destacando seu propósito e principal funcionalidade.
|
||||
Esta sessão resume o conteúdo e o objetivo do ativo, ajudando os usuários a entender rapidamente o que o ativo representa
|
||||
e como pode ser utilizado em suas análises e decisões.
|
||||
- Sugestão de Domínio de Dados:
|
||||
Analise cuidadosamente os dados da tabela e sugira o domínio mais apropriado. Inclua:
|
||||
- Domínio Sugerido: [Nome do domínio]
|
||||
- Motivo: [Explicação breve sobre porque a tabela pertence a este domínio]
|
||||
- Observações: [Qualquer observação adicional relevante]
|
||||
|
||||
Exemplos de Domínios de Dados para referência:
|
||||
- Financeiro: Dados sobre transações, receitas, despesas, etc.
|
||||
- Recursos Humanos: Dados sobre funcionários, cargos, salários, etc.
|
||||
- Produtos: Dados sobre produtos, categorias, preços, etc.
|
||||
- Fornecedores: Dados sobre fornecedores, produtos fornecidos, localizações, etc.
|
||||
- Marketing: Dados sobre campanhas, leads, conversões, etc.
|
||||
- Vendas: Dados sobre vendas, clientes, produtos vendidos, etc.
|
||||
- Operações: Dados sobre processos, logística, produção, etc.
|
||||
- Clientes: Dados sobre clientes, interações, histórico, etc.
|
||||
-Tags Sugeridas:
|
||||
A IA deve gerar tags relevantes **com base nos dados da tabela**.
|
||||
- **IMPORTANTE: Analise cuidadosamente os dados de preview da tabela (PREVIEW DATA) para encontrar países. Procure em todas as colunas por nomes de países, cidades ou regiões.**
|
||||
- **Garanta que as tags estejam separadas por espaços vazios, todas na mesma linha, exemplo: #marketing #sales #australia #canada, limitar até 3 países que mais aparecem** - **Os países DEVEM ser extraídos dos dados de preview da tabela. Procure em colunas como City, Country, Region, Location, etc.** - Por que esta tabela é interessante:
|
||||
Nesta sessão, é destacada a importância do ativo, explicando como ele pode ser útil para os usuários.
|
||||
São abordadas as formas como o ativo pode melhorar a tomada de decisões, identificar padrões relevantes ou fornecer insights valiosos.
|
||||
O objetivo é ressaltar a utilidade prática e o impacto positivo que o ativo pode ter em suas atividades.
|
||||
- Análises potencialmente úteis feitas com esses dados:
|
||||
Aqui são listadas algumas das análises que podem ser realizadas com o ativo de dados. Inclui sugestões de dashboards,
|
||||
relatórios ou outros tipos de análises que aproveitam as informações fornecidas pelo ativo.
|
||||
O objetivo é oferecer maneiras de utilizar os dados para obter insights valiosos e apoiar a tomada de decisões informadas.
|
||||
- Links Úteis:
|
||||
Os Links Úteis oferecem recursos adicionais relacionados ao ativo de dados, incluindo guias,
|
||||
artigos ou outras fontes de informação que podem ajudar os usuários a compreender melhor o ativo e suas aplicações. Além disso,
|
||||
inclui um link rápido dentro da Dadosfera para ativos relacionados diretamente com o ativo em questão, facilitando a navegação entre os ativos.
|
||||
- Estrutura da Tabela:
|
||||
A Estrutura da Tabela detalha TODAS as colunas e os dados disponíveis no ativo, conforme pertencentes ao 'Table Schema' principal definido no início deste documento.
|
||||
**Instrução Detalhada para Estrutura da Tabela:**
|
||||
Siga rigorosamente estes passos:
|
||||
1. Identifique nos dados de entrada (metadados da tabela e das colunas) todas as colunas que pertencem EXCLUSIVAMENTE ao 'Table Schema' especificado no cabeçalho deste documento. Se houver uma contagem de colunas (ex: 'Num_columns') para este schema específico, assegure-se de listar essa quantidade.
|
||||
2. Para CADA uma dessas colunas identificadas, formate a saída da seguinte maneira, **SEM utilizar NENHUM marcador de lista (como traços ou asteriscos) no início de cada entrada de coluna**. Cada coluna deve ser apresentada como um bloco de texto. Inclua uma linha em branco entre a documentação de cada coluna para separação visual.
|
||||
- Apresente o NOME_DA_COLUNA em maiúsculas, seguido pelo (TIPO_DE_DADO_EXTRAÍDO_DOS_METADADOS_DO_SCHEMA_CORRETO) entre parênteses.
|
||||
- O **NOME_DA_COLUNA (TIPO_DE_DADO_EXTRAÍDO_DOS_METADADOS_DO_SCHEMA_CORRETO)** deve estar na primeira linha do bloco da coluna e **inteiramente em negrito**.
|
||||
- Na linha seguinte, a etiqueta "**Descrição:**" deve estar **em negrito**, seguida pelo texto da descrição da coluna.
|
||||
- Na linha seguinte à descrição, a etiqueta "**Exemplo:**" deve estar **em negrito**, seguida pelo valor do exemplo. Se o exemplo for um valor literal ou código, formate-o entre crases (\`) se apropriado.
|
||||
- Se houver informações adicionais relevantes (como "Valores Possíveis:", "Observações:", etc.), coloque a etiqueta correspondente **em negrito** em uma nova linha, seguida pelo seu texto.
|
||||
|
||||
Este documento foi gerado por IA.
|
||||
|
||||
NOME_COLUNA_1 (TIPO_DADO_SCHEMA_CORRETO_1):
|
||||
Descrição: [Descrição da coluna 1, do schema correto]
|
||||
Exemplo: \`[Exemplo de valor para coluna 1, do schema correto]\`
|
||||
|
||||
NOME_COLUNA_2 (TIPO_DADO_SCHEMA_CORRETO_2):
|
||||
Descrição: [Descrição da coluna 2, do schema correto]
|
||||
Exemplo: \`[Exemplo de valor para coluna 2, do schema correto]\`
|
||||
|
||||
(continue este formato com início de cada coluna para TODAS as colunas do 'Table Schema' especificado, garanta com que NUNCA tenha TRAÇO OU PONTO no inicio)
|
||||
|
||||
3. Contexto : O usuário ira cadastrar um ativo de dados na nossa plataforma e para ter um bom catalogo ele ira querer gerar a documentação padronizada mas explicativa e
|
||||
automática
|
||||
4. Restrições : A documentação deve seguir obrigatoriamente o mesmo padrão principalmente na parte de estrutura de dados
|
||||
5. Objetivo: O principal objetivo é gerar uma documentação acessível, clara,
|
||||
automática e padronizada para os usuários que desejem cadastrar um ativo de dados na plataforma`;
|
||||
|
||||
/**
|
||||
* Configurações para a geração de documentação com IA
|
||||
*/
|
||||
export const AI_DOCUMENTATION_CONFIG = {
|
||||
FETCH_K: 250,
|
||||
K: 100,
|
||||
} as const;
|
||||
@@ -160,7 +160,7 @@ export class ShareService implements OnModuleInit {
|
||||
});
|
||||
|
||||
const { documentation } = await lastValueFrom(
|
||||
this.catalogReadService.GetDatasetDoc({ id }, metadata),
|
||||
this.catalogReadService.GetDatasetDoc({ id, type: undefined }, metadata),
|
||||
);
|
||||
console.log(documentation);
|
||||
const docs = JSON.parse(documentation);
|
||||
@@ -180,7 +180,7 @@ export class ShareService implements OnModuleInit {
|
||||
return data_assets.map((data_asset) => {
|
||||
const owner = customer_users.find(
|
||||
(u) => u.id === data_asset.owner,
|
||||
)?.email;
|
||||
)?.username;
|
||||
|
||||
const roles = [];
|
||||
const users = [];
|
||||
@@ -190,7 +190,7 @@ export class ShareService implements OnModuleInit {
|
||||
}
|
||||
for (const user_id of data_asset.users) {
|
||||
const user = customer_users.find((r) => r.id === user_id);
|
||||
if (user) users.push({ id: user.id, email: user.email });
|
||||
if (user) users.push({ id: user.id, username: user.username });
|
||||
}
|
||||
return {
|
||||
...data_asset,
|
||||
@@ -262,7 +262,6 @@ export class ShareService implements OnModuleInit {
|
||||
user_id: accessTokenPayload.user_id,
|
||||
username: accessTokenPayload.username,
|
||||
permissions: accessTokenPayload.permissions,
|
||||
roles: accessTokenPayload.roles,
|
||||
customer_id: accessTokenPayload.customer_id,
|
||||
customer_name: accessTokenPayload.customer_name,
|
||||
customer_tier: accessTokenPayload.customer_tier,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tipos relacionados à geração de documentação com IA
|
||||
*/
|
||||
|
||||
export interface AutodriveCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
authHeader?: string;
|
||||
}
|
||||
|
||||
export interface AutodriveAskPayload {
|
||||
question: string;
|
||||
fetch_k: number;
|
||||
k: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface AutodriveAskResponse {
|
||||
answer?: string;
|
||||
question_id?: string;
|
||||
dataset_id?: string;
|
||||
}
|
||||
|
||||
export interface AutodriveAnswerResponse {
|
||||
status: 'started' | 'success' | 'failed';
|
||||
answer?: string;
|
||||
status_reason?: string;
|
||||
}
|
||||
|
||||
export interface AutodriveUploadResponse {
|
||||
dataset_id: string;
|
||||
}
|
||||
|
||||
export interface DatasetStatusResponse {
|
||||
status: 'processing' | 'success' | 'failed';
|
||||
status_reason?: string;
|
||||
}
|
||||
|
||||
export interface ColumnData {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
nullable?: string;
|
||||
}
|
||||
|
||||
export interface ColumnsMetadata {
|
||||
columns: ColumnData[];
|
||||
}
|
||||
|
||||
export interface DataPreview {
|
||||
preview: any[];
|
||||
}
|
||||
|
||||
export interface FormattedDataForAI {
|
||||
dataAsset: any;
|
||||
dataPreview: any[];
|
||||
columnsData?: ColumnsMetadata;
|
||||
}
|
||||
@@ -22,26 +22,17 @@ import {
|
||||
ConnectionTestListTablesRes,
|
||||
GetTableMetadataRes,
|
||||
GetTableMetadataReq,
|
||||
ValidateCdcPrerequisitesReq,
|
||||
ValidateCdcPrerequisitesRes,
|
||||
RefreshCatalogReq,
|
||||
RefreshCatalogRes,
|
||||
RefreshCatalogStatusReq,
|
||||
} from './dto/connection-test';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { Authenticated, RequireModule } from 'src/decorators/authentication.decorator';
|
||||
import { Authenticated } from 'src/decorators/authentication.decorator';
|
||||
import { GrpcToHttpExceptionFilter } from 'src/error/grpc-to-http-exception.filter';
|
||||
import { ApiInternalOnlyController } from 'src/decorators/swagger.decorator';
|
||||
import { DADOSFERA_MODULES_KEYS } from 'src/authentication/permissions.enum';
|
||||
|
||||
@ApiInternalOnlyController()
|
||||
@ApiTags('Connection Test')
|
||||
@Controller('connection-test')
|
||||
@UseFilters(new GrpcToHttpExceptionFilter())
|
||||
@Authenticated()
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
export class ConnectionTestController {
|
||||
logger: any;
|
||||
constructor(
|
||||
@@ -93,7 +84,7 @@ export class ConnectionTestController {
|
||||
});
|
||||
return this.connectionTestService.connectionTestListSchemas(
|
||||
body,
|
||||
user,
|
||||
user.customer_name,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,7 +101,7 @@ export class ConnectionTestController {
|
||||
});
|
||||
return this.connectionTestService.connectionTestListTables(
|
||||
body,
|
||||
user,
|
||||
user.customer_name,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,56 +117,8 @@ export class ConnectionTestController {
|
||||
customer: user.customer_name,
|
||||
});
|
||||
return this.connectionTestService.getTableMetadata(
|
||||
body,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('cdc-prerequisites')
|
||||
@ApiOkResponse({ type: ValidateCdcPrerequisitesRes })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async validateCdcPrerequisites(
|
||||
@User() user: RequestUser,
|
||||
@Body(new ValidationPipe()) body: ValidateCdcPrerequisitesReq,
|
||||
) {
|
||||
this.logger.info('/connection-test/cdc-prerequisites', {
|
||||
user: user.user_id,
|
||||
customer: user.customer_name,
|
||||
});
|
||||
return this.connectionTestService.validateCdcPrerequisites(
|
||||
body,
|
||||
user.customer_name,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('refresh-catalog')
|
||||
@ApiOkResponse({ type: RefreshCatalogRes })
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
async refreshCatalog(
|
||||
@User() user: RequestUser,
|
||||
@Body(new ValidationPipe()) body: RefreshCatalogReq,
|
||||
) {
|
||||
this.logger.info('/connection-test/refresh-catalog', {
|
||||
user: user.user_id,
|
||||
customer: user.customer_name,
|
||||
connection: body.connection_id,
|
||||
});
|
||||
return this.connectionTestService.refreshCatalog(body, user);
|
||||
}
|
||||
|
||||
@Post('refresh-catalog/status')
|
||||
@ApiOkResponse({ type: RefreshCatalogRes })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refreshCatalogStatus(
|
||||
@User() user: RequestUser,
|
||||
@Body(new ValidationPipe()) body: RefreshCatalogStatusReq,
|
||||
) {
|
||||
this.logger.info('/connection-test/refresh-catalog/status', {
|
||||
user: user.user_id,
|
||||
customer: user.customer_name,
|
||||
connection: body.connection_id,
|
||||
session: body.session_id,
|
||||
});
|
||||
return this.connectionTestService.refreshCatalogStatus(body, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,10 @@ import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { ConnectionTestClientConfiguration } from './connection-test-client.config';
|
||||
import { ConnectionModule } from '../connection/connection.module';
|
||||
import { ConnectionsApiModule } from '../connections-api/connections-api.module';
|
||||
import { PlatformApiModule } from '../platform-api/platform-api.module';
|
||||
const client = new ConnectionTestClientConfiguration();
|
||||
@Module({
|
||||
controllers: [ConnectionTestController],
|
||||
providers: [ConnectionTestService, DadosferaLogger],
|
||||
imports: [
|
||||
ClientsModule.register([client.providerOptions]),
|
||||
ConnectionModule,
|
||||
ConnectionsApiModule,
|
||||
PlatformApiModule,
|
||||
],
|
||||
imports: [ClientsModule.register([client.providerOptions]), ConnectionModule],
|
||||
})
|
||||
export class ConnectionTestModule {}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { ConnectionTestService } from './connection-test.service';
|
||||
import { RequestUser } from 'src/decorators/user.decorator';
|
||||
|
||||
describe('ConnectionTestService catalog cache', () => {
|
||||
const user: RequestUser = {
|
||||
user_id: 'user-id',
|
||||
username: 'user@example.com',
|
||||
permissions: [],
|
||||
customer_id: 'customer-id',
|
||||
customer_name: 'customer-name',
|
||||
customer_tier: 'standard',
|
||||
access_token: 'token',
|
||||
customer_modules: [],
|
||||
roles: [],
|
||||
};
|
||||
const grpcClient = { getService: jest.fn().mockReturnValue({}) };
|
||||
const connectionsService = {};
|
||||
const connectionsApiService = { proxy: jest.fn() };
|
||||
const platformApiService = { proxy: jest.fn() };
|
||||
let service: ConnectionTestService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new ConnectionTestService(
|
||||
grpcClient as any,
|
||||
connectionsService as any,
|
||||
connectionsApiService as any,
|
||||
platformApiService as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the existing schemas response contract', async () => {
|
||||
connectionsApiService.proxy.mockResolvedValue({
|
||||
schemas: [{ schema_name: 'analytics' }, { schema_name: 'public' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.connectionTestListSchemas(
|
||||
{ connection_id: 'config-id', plugin: 'postgresql' },
|
||||
user,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
operation_result: true,
|
||||
schema_list: ['analytics', 'public'],
|
||||
});
|
||||
});
|
||||
|
||||
it('lists tables and enriches each with its cached primary keys', async () => {
|
||||
connectionsApiService.proxy
|
||||
// list-tables call (names only from the catalog cache)
|
||||
.mockResolvedValueOnce({
|
||||
tables: [{ table_name: 'customers' }, { table_name: 'orders' }],
|
||||
})
|
||||
// per-table columns calls: customers has a PK, orders has none
|
||||
.mockResolvedValueOnce({
|
||||
columns: [
|
||||
{ column_name: 'id', data_type: 'bigint', is_primary_key: true },
|
||||
{ column_name: 'name', data_type: 'text', is_primary_key: false },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
columns: [
|
||||
{ column_name: 'total', data_type: 'numeric', is_primary_key: false },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.connectionTestListTables(
|
||||
{
|
||||
connection_id: 'config-id',
|
||||
plugin: 'postgresql',
|
||||
schema: 'public',
|
||||
},
|
||||
user,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
operation_result: true,
|
||||
table_list: ['customers', 'orders'],
|
||||
tables: [
|
||||
{ table_name: 'customers', primary_keys: ['id'] },
|
||||
{ table_name: 'orders', primary_keys: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps cached columns to the existing table metadata contract', async () => {
|
||||
connectionsApiService.proxy.mockResolvedValue({
|
||||
columns: [
|
||||
{
|
||||
column_name: 'id',
|
||||
data_type: 'bigint',
|
||||
is_primary_key: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getTableMetadata(
|
||||
{
|
||||
connection_id: 'config-id',
|
||||
plugin: 'postgresql',
|
||||
schema: 'public',
|
||||
table_list: ['customers'],
|
||||
},
|
||||
user,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
operation_result: true,
|
||||
tables_metadata: [
|
||||
{
|
||||
table_name: 'customers',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigint',
|
||||
is_primary_key: true,
|
||||
},
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(connectionsApiService.proxy).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/connection_catalog/config-id/schemas/public/tables/customers/columns',
|
||||
user,
|
||||
);
|
||||
});
|
||||
|
||||
it('submits a catalog refresh without holding the request open', async () => {
|
||||
platformApiService.proxy.mockResolvedValue({
|
||||
session_id: 'session-id',
|
||||
date: '20260731',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.refreshCatalog(
|
||||
{ connection_id: 'config-id', plugin: 'postgresql' },
|
||||
user,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
operation_result: true,
|
||||
status: 'PENDING',
|
||||
session_id: 'session-id',
|
||||
date: '20260731',
|
||||
});
|
||||
|
||||
expect(platformApiService.proxy).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/connection_test',
|
||||
user,
|
||||
{
|
||||
customer_id: user.customer_name,
|
||||
plugin: 'postgresql',
|
||||
task: {
|
||||
task_type: 'refresh_catalog',
|
||||
connection: {
|
||||
provider: 'connection_manager',
|
||||
config_id: 'config-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling without changing the catalog pointer while pending', async () => {
|
||||
platformApiService.proxy.mockResolvedValue({ status: 'PENDING' });
|
||||
|
||||
await expect(
|
||||
service.refreshCatalogStatus(
|
||||
{
|
||||
connection_id: 'config-id',
|
||||
plugin: 'postgresql',
|
||||
session_id: 'session-id',
|
||||
date: '20260731',
|
||||
},
|
||||
user,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
operation_result: false,
|
||||
status: 'PENDING',
|
||||
session_id: 'session-id',
|
||||
date: '20260731',
|
||||
});
|
||||
|
||||
expect(connectionsApiService.proxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('publishes the catalog pointer after the refresh finishes', async () => {
|
||||
platformApiService.proxy.mockResolvedValue({ status: 'DONE' });
|
||||
connectionsApiService.proxy.mockResolvedValue({
|
||||
last_catalog_refresh_status: 'SUCCESS',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.refreshCatalogStatus(
|
||||
{
|
||||
connection_id: 'config/id',
|
||||
plugin: 'postgresql',
|
||||
session_id: 'session-id',
|
||||
date: '20260731',
|
||||
},
|
||||
user,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
operation_result: true,
|
||||
status: 'DONE',
|
||||
session_id: 'session-id',
|
||||
date: '20260731',
|
||||
});
|
||||
|
||||
expect(connectionsApiService.proxy).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/connection_config/config%2Fid/catalog_metadata',
|
||||
user,
|
||||
{
|
||||
last_catalog_refresh_status: 'SUCCESS',
|
||||
last_catalog_connection_test_date: '20260731',
|
||||
last_catalog_connection_test_session_id: 'session-id',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ClientGrpc } from '@nestjs/microservices';
|
||||
import { ConnectionTest } from '@dadosfera/protospack-v2';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
@@ -13,11 +13,6 @@ import {
|
||||
ConnectionTestPingRes,
|
||||
GetTableMetadataReq,
|
||||
GetTableMetadataRes,
|
||||
ValidateCdcPrerequisitesReq,
|
||||
ValidateCdcPrerequisitesRes,
|
||||
RefreshCatalogReq,
|
||||
RefreshCatalogRes,
|
||||
RefreshCatalogStatusReq,
|
||||
} from './dto/connection-test';
|
||||
import { ConnectionClientService } from '../connection/client.service';
|
||||
import {
|
||||
@@ -26,8 +21,6 @@ import {
|
||||
} from '../connection/dtos/connection';
|
||||
import { RequestUser } from 'src/decorators/user.decorator';
|
||||
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
|
||||
import { ConnectionsApiService } from '../connections-api/connections-api.service';
|
||||
import { PlatformApiService } from '../platform-api/platform-api.service';
|
||||
|
||||
@Injectable()
|
||||
export class ConnectionTestService {
|
||||
@@ -35,8 +28,6 @@ export class ConnectionTestService {
|
||||
constructor(
|
||||
@Inject('ConnectionTestGrpcClient') private readonly grpcClient: ClientGrpc,
|
||||
private connectionsService: ConnectionClientService,
|
||||
private connectionsApiService: ConnectionsApiService,
|
||||
private platformApiService: PlatformApiService,
|
||||
) {
|
||||
this.connectionTestReadClient =
|
||||
grpcClient.getService<ConnectionTest.ReadService.ConnectionTestReadServices>(
|
||||
@@ -156,175 +147,45 @@ export class ConnectionTestService {
|
||||
}
|
||||
async connectionTestListSchemas(
|
||||
body: ConnectionTestListSchemasReq,
|
||||
user: RequestUser,
|
||||
): Promise<ConnectionTestListSchemasRes> {
|
||||
const result = await this.connectionsApiService.proxy(
|
||||
'GET',
|
||||
`/connection_catalog/${encodeURIComponent(body.connection_id)}/schemas`,
|
||||
user,
|
||||
);
|
||||
return {
|
||||
operation_result: true,
|
||||
schema_list: result.schemas.map((schema) => schema.schema_name),
|
||||
};
|
||||
}
|
||||
|
||||
async connectionTestListTables(
|
||||
body: ConnectionTestListTablesReq,
|
||||
user: RequestUser,
|
||||
): Promise<ConnectionTestListTablesRes> {
|
||||
const result = await this.connectionsApiService.proxy(
|
||||
'GET',
|
||||
`/connection_catalog/${encodeURIComponent(body.connection_id)}` +
|
||||
`/schemas/${encodeURIComponent(body.schema)}/tables`,
|
||||
user,
|
||||
);
|
||||
const table_names: string[] = result.tables.map((table) => table.table_name);
|
||||
// CDC create needs the primary keys per table (used to build the deduped
|
||||
// Iceberg identifier-fields). The catalog-cache list-tables endpoint returns
|
||||
// only names, so fetch each table's columns from the cache and keep the ones
|
||||
// flagged is_primary_key. Reads hit the stored catalog snapshot (populated by
|
||||
// refresh-catalog), never the live connection.
|
||||
const tables = await Promise.all(
|
||||
table_names.map(async (table_name) => {
|
||||
const columns = await this.connectionsApiService.proxy(
|
||||
'GET',
|
||||
`/connection_catalog/${encodeURIComponent(body.connection_id)}` +
|
||||
`/schemas/${encodeURIComponent(body.schema)}` +
|
||||
`/tables/${encodeURIComponent(table_name)}/columns`,
|
||||
user,
|
||||
);
|
||||
return {
|
||||
table_name,
|
||||
primary_keys: columns.columns
|
||||
.filter((column) => column.is_primary_key)
|
||||
.map((column) => column.column_name),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
operation_result: true,
|
||||
table_list: table_names,
|
||||
tables,
|
||||
};
|
||||
}
|
||||
|
||||
async getTableMetadata(
|
||||
body: GetTableMetadataReq,
|
||||
user: RequestUser,
|
||||
): Promise<GetTableMetadataRes> {
|
||||
const tables_metadata = await Promise.all(
|
||||
body.table_list.map(async (table_name) => {
|
||||
const result = await this.connectionsApiService.proxy(
|
||||
'GET',
|
||||
`/connection_catalog/${encodeURIComponent(body.connection_id)}` +
|
||||
`/schemas/${encodeURIComponent(body.schema)}` +
|
||||
`/tables/${encodeURIComponent(table_name)}/columns`,
|
||||
user,
|
||||
);
|
||||
return {
|
||||
table_name,
|
||||
columns: result.columns.map((column) => ({
|
||||
name: column.column_name,
|
||||
type: column.data_type,
|
||||
is_primary_key: column.is_primary_key,
|
||||
})),
|
||||
references: [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { operation_result: true, tables_metadata };
|
||||
}
|
||||
|
||||
async refreshCatalog(
|
||||
body: RefreshCatalogReq,
|
||||
user: RequestUser,
|
||||
): Promise<RefreshCatalogRes> {
|
||||
const task = await this.platformApiService.proxy(
|
||||
'POST',
|
||||
'/connection_test',
|
||||
user,
|
||||
{
|
||||
customer_id: user.customer_name,
|
||||
plugin: body.plugin,
|
||||
task: {
|
||||
task_type: 'refresh_catalog',
|
||||
connection: {
|
||||
provider: 'connection_manager',
|
||||
config_id: body.connection_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!task.session_id || !task.date) {
|
||||
throw new HttpException(
|
||||
'Platform API did not return a catalog refresh task identifier',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
operation_result: true,
|
||||
status: 'PENDING',
|
||||
session_id: task.session_id,
|
||||
date: task.date,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshCatalogStatus(
|
||||
body: RefreshCatalogStatusReq,
|
||||
user: RequestUser,
|
||||
): Promise<RefreshCatalogRes> {
|
||||
const result = await this.platformApiService.proxy(
|
||||
'POST',
|
||||
'/connection_test/status',
|
||||
user,
|
||||
{
|
||||
session_id: body.session_id,
|
||||
date: body.date,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status === 'DONE') {
|
||||
await this.connectionsApiService.proxy(
|
||||
'PUT',
|
||||
`/connection_config/${encodeURIComponent(
|
||||
body.connection_id,
|
||||
)}/catalog_metadata`,
|
||||
user,
|
||||
{
|
||||
last_catalog_refresh_status: 'SUCCESS',
|
||||
last_catalog_connection_test_date: body.date,
|
||||
last_catalog_connection_test_session_id: body.session_id,
|
||||
},
|
||||
);
|
||||
} else if (result.status === 'ERROR' || result.status === 'EXPIRED') {
|
||||
throw new HttpException(
|
||||
`Catalog refresh finished with status ${result.status}`,
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
operation_result: result.status === 'DONE',
|
||||
status: result.status,
|
||||
session_id: body.session_id,
|
||||
date: body.date,
|
||||
};
|
||||
}
|
||||
|
||||
async validateCdcPrerequisites(
|
||||
body: ValidateCdcPrerequisitesReq,
|
||||
customer_name: string,
|
||||
): Promise<ValidateCdcPrerequisitesRes> {
|
||||
const { plugin, connection_id } = body;
|
||||
): Promise<ConnectionTestListSchemasRes> {
|
||||
const { connection_id, plugin } = body;
|
||||
return lastValueFrom(
|
||||
this.connectionTestReadClient.ValidateCdcPrerequisites({
|
||||
this.connectionTestReadClient.ListSchemas({
|
||||
connection_id,
|
||||
customer_name,
|
||||
plugin,
|
||||
}),
|
||||
);
|
||||
}
|
||||
async connectionTestListTables(
|
||||
body: ConnectionTestListTablesReq,
|
||||
customer_name: string,
|
||||
): Promise<ConnectionTestListTablesRes> {
|
||||
const { connection_id, plugin, schema } = body;
|
||||
return lastValueFrom(
|
||||
this.connectionTestReadClient.ListTables({
|
||||
connection_id,
|
||||
customer_name,
|
||||
plugin,
|
||||
schema,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getTableMetadata(
|
||||
body: GetTableMetadataReq,
|
||||
customer_name: string,
|
||||
): Promise<GetTableMetadataRes> {
|
||||
const { schema, plugin, table_list, connection_id } = body;
|
||||
return lastValueFrom(
|
||||
this.connectionTestReadClient.GetTableMetadata({
|
||||
connection_id,
|
||||
customer_name,
|
||||
plugin,
|
||||
schema,
|
||||
table_list,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsIn, IsString, IsOptional } from 'class-validator';
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
import { DatabaseConnectionPropertiesDto } from 'src/modules/connection/dtos/connection';
|
||||
import { CreateConnectionDto } from 'src/modules/connection/dtos/connection';
|
||||
export class ColumnDto {
|
||||
@@ -7,8 +7,6 @@ export class ColumnDto {
|
||||
name: string;
|
||||
@ApiProperty()
|
||||
type: string;
|
||||
@ApiProperty()
|
||||
is_primary_key: boolean;
|
||||
}
|
||||
export class TableMetadataDto {
|
||||
@ApiProperty()
|
||||
@@ -102,20 +100,11 @@ export class ConnectionTestListTablesReq {
|
||||
schema: string;
|
||||
}
|
||||
|
||||
export class ConnectionTestListTablesEntry {
|
||||
@ApiProperty()
|
||||
table_name: string;
|
||||
@ApiProperty({ type: [String] })
|
||||
primary_keys: string[];
|
||||
}
|
||||
|
||||
export class ConnectionTestListTablesRes {
|
||||
@ApiProperty()
|
||||
operation_result: boolean;
|
||||
@ApiProperty()
|
||||
table_list: string[];
|
||||
@ApiProperty({ type: [ConnectionTestListTablesEntry] })
|
||||
tables: ConnectionTestListTablesEntry[];
|
||||
}
|
||||
|
||||
export class GetTableMetadataReq {
|
||||
@@ -142,83 +131,3 @@ export class GetTableMetadataRes {
|
||||
@ApiProperty({ type: [TableMetadataDto] })
|
||||
tables_metadata: TableMetadataDto[];
|
||||
}
|
||||
|
||||
export class CdcCheckDto {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@ApiProperty()
|
||||
expected: string;
|
||||
@ApiProperty()
|
||||
actual: string;
|
||||
@ApiProperty()
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export class ValidateCdcPrerequisitesReq {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
plugin: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
connection_id: string;
|
||||
}
|
||||
|
||||
export class ValidateCdcPrerequisitesRes {
|
||||
@ApiProperty()
|
||||
operation_result: boolean;
|
||||
@ApiProperty({ type: [CdcCheckDto] })
|
||||
checks: CdcCheckDto[];
|
||||
}
|
||||
|
||||
export class RefreshCatalogReq {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
connection_id: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: [
|
||||
'oracle',
|
||||
'mysql',
|
||||
'postgresql',
|
||||
'sqlserver',
|
||||
'mysql_cdc',
|
||||
'postgresql_cdc',
|
||||
'oracle_cdc',
|
||||
],
|
||||
})
|
||||
@IsIn([
|
||||
'oracle',
|
||||
'mysql',
|
||||
'postgresql',
|
||||
'sqlserver',
|
||||
'mysql_cdc',
|
||||
'postgresql_cdc',
|
||||
'oracle_cdc',
|
||||
])
|
||||
plugin: string;
|
||||
}
|
||||
|
||||
export class RefreshCatalogStatusReq extends RefreshCatalogReq {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
session_id: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
date: string;
|
||||
}
|
||||
|
||||
export class RefreshCatalogRes {
|
||||
@ApiProperty()
|
||||
operation_result: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
session_id: string;
|
||||
|
||||
@ApiProperty()
|
||||
date: string;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import {
|
||||
Authenticated,
|
||||
RequireAllPermissions,
|
||||
RequireModule,
|
||||
} from 'src/decorators/authentication.decorator';
|
||||
import { DADOSFERA_MODULES_KEYS, PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum';
|
||||
import { PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum';
|
||||
import { RequestUser, User } from 'src/decorators/user.decorator';
|
||||
import { ValidationPipe } from '../../pipes/object-validation.pipe';
|
||||
import {
|
||||
@@ -40,9 +39,6 @@ const connectionPermissions = PERMISSIONS_GROUPS.CONNECTION.permissions;
|
||||
@ApiTags('connections')
|
||||
@Authenticated()
|
||||
@Controller('connections')
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
export class ConnectionController {
|
||||
logger: any;
|
||||
constructor(
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const CONNECTIONS_API_CONFIG = {
|
||||
getUrl: (): string => {
|
||||
const url = process.env.CONNECTIONS_API_URL;
|
||||
if (!url) {
|
||||
throw new Error('CONNECTIONS_API_URL environment variable is not set');
|
||||
}
|
||||
return url;
|
||||
},
|
||||
region: process.env.AWS_REGION || 'us-east-1',
|
||||
timeout: parseInt(process.env.CONNECTIONS_API_TIMEOUT || '30000', 10),
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { ConnectionsApiService } from './connections-api.service';
|
||||
|
||||
@Module({
|
||||
providers: [ConnectionsApiService, DadosferaLogger],
|
||||
exports: [ConnectionsApiService],
|
||||
})
|
||||
export class ConnectionsApiModule {}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Injectable, Inject, HttpException } from '@nestjs/common';
|
||||
import { SignatureV4 } from '@aws-sdk/signature-v4';
|
||||
import { Sha256 } from '@aws-crypto/sha256-js';
|
||||
import { defaultProvider } from '@aws-sdk/credential-provider-node';
|
||||
import axios, { AxiosResponse, Method } from 'axios';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { RequestUser } from '../../decorators/user.decorator';
|
||||
import { CONNECTIONS_API_CONFIG } from './connections-api.config';
|
||||
|
||||
@Injectable()
|
||||
export class ConnectionsApiService {
|
||||
private signer: SignatureV4;
|
||||
private logger: any;
|
||||
|
||||
constructor(@Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
this.signer = new SignatureV4({
|
||||
service: 'execute-api',
|
||||
region: CONNECTIONS_API_CONFIG.region,
|
||||
credentials: defaultProvider(),
|
||||
sha256: Sha256,
|
||||
});
|
||||
}
|
||||
|
||||
async proxy(
|
||||
method: string,
|
||||
path: string,
|
||||
user: RequestUser,
|
||||
body?: any,
|
||||
query?: Record<string, string>,
|
||||
): Promise<any> {
|
||||
const baseUrl = CONNECTIONS_API_CONFIG.getUrl();
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
host: url.hostname,
|
||||
'content-type': 'application/json',
|
||||
customer_name: user.customer_name || '',
|
||||
customer_id: user.customer_id || '',
|
||||
'x-user-id': user.user_id || '',
|
||||
'x-username': user.username || '',
|
||||
'x-customer-tier': user.customer_tier || '',
|
||||
'x-customer-id': user.customer_id || '',
|
||||
};
|
||||
const requestToSign = {
|
||||
method: method.toUpperCase(),
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||||
path: url.pathname + url.search,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const signedRequest = await this.signer.sign(requestToSign);
|
||||
const response: AxiosResponse = await axios({
|
||||
method: method as Method,
|
||||
url: url.href,
|
||||
headers: signedRequest.headers as Record<string, string>,
|
||||
data: body,
|
||||
timeout: CONNECTIONS_API_CONFIG.timeout,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new HttpException(response.data, response.status);
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Connections API proxy error', {
|
||||
error: error.message,
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
});
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
if (error.response) {
|
||||
throw new HttpException(error.response.data, error.response.status);
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new HttpException('Connections API service unavailable', 503);
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
|
||||
throw new HttpException('Connections API request timeout', 504);
|
||||
}
|
||||
throw new HttpException('Internal server error', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,9 @@ import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import {
|
||||
Authenticated,
|
||||
RequireAllPermissions,
|
||||
RequireModule,
|
||||
RequireSomePermission,
|
||||
} from 'src/decorators/authentication.decorator';
|
||||
import { DADOSFERA_MODULES_KEYS, PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum';
|
||||
import { PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum';
|
||||
import { Language } from 'src/decorators/language.decorator';
|
||||
import { LanguageEnum } from 'src/utils/languages.enum';
|
||||
import { ApiInternalOnlyController } from 'src/decorators/swagger.decorator';
|
||||
@@ -100,9 +99,6 @@ export class ConnectorController {
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.DELETE,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async getAllConnectors(
|
||||
@Language() language: LanguageEnum,
|
||||
@Query() queries: GetAllDto,
|
||||
@@ -135,9 +131,6 @@ export class ConnectorController {
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.DELETE,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async getConnectorsTags() {
|
||||
return await this.connectorClientService.getConnectorsTags();
|
||||
}
|
||||
@@ -150,9 +143,6 @@ export class ConnectorController {
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.DELETE,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async getConnector(
|
||||
@Language() language: LanguageEnum,
|
||||
@Param('plugin') plugin: string,
|
||||
@@ -181,9 +171,6 @@ export class ConnectorController {
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE,
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.DELETE,
|
||||
)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async getConnectorDetails(
|
||||
@Language() language: LanguageEnum,
|
||||
@Param('plugin') plugin: string,
|
||||
@@ -206,9 +193,6 @@ export class ConnectorController {
|
||||
@Put('/:plugin')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.CONNECTORS.permissions.UPDATE)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async updateConnector(
|
||||
@Param('plugin') plugin: string,
|
||||
@Body() body: UpdateDto,
|
||||
@@ -230,9 +214,6 @@ export class ConnectorController {
|
||||
|
||||
@Put('/:plugin/add-tag')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.CONNECTORS.permissions.UPDATE)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async addTagOnConnector(
|
||||
@Param('plugin') plugin: string,
|
||||
@Body() body: AddTagDto,
|
||||
@@ -260,9 +241,6 @@ export class ConnectorController {
|
||||
|
||||
@Put('/:plugin/remove-tag')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.CONNECTORS.permissions.UPDATE)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async removeTagOnConnector(
|
||||
@Param('plugin') plugin: string,
|
||||
@Body() body: RemoveTagDto,
|
||||
@@ -291,9 +269,6 @@ export class ConnectorController {
|
||||
|
||||
@Delete('/:plugin')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.CONNECTORS.permissions.DELETE)
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
async deleteConnector(
|
||||
@Param('plugin') plugin: string,
|
||||
@Query('version') version: string,
|
||||
|
||||
@@ -136,32 +136,4 @@ export class CustomersController {
|
||||
const result = await this.customersService.getAccessDashboardUrl(user.customer_name, metadata);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id/organization-info')
|
||||
@Authenticated()
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.USERS.permissions.ADMIN)
|
||||
@ApiOkResponse({ description: 'Organization information' })
|
||||
async getOrganizationInfo(@Param('id') id: string) {
|
||||
this.logger.info('getOrganizationInfo', { id });
|
||||
return this.customersService.getOrganizationInfo(id);
|
||||
}
|
||||
|
||||
@Put(':id/organization-info')
|
||||
@Authenticated()
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.USERS.permissions.ADMIN)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ description: 'Organization information updated' })
|
||||
async updateOrganizationInfo(
|
||||
@Param('id') id: string,
|
||||
@Body() body: {
|
||||
companyName: string;
|
||||
companySite: string;
|
||||
domain: string;
|
||||
cnpj: string;
|
||||
description: string;
|
||||
},
|
||||
) {
|
||||
return this.customersService.updateOrganizationInfo(id, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { firstValueFrom, lastValueFrom } from 'rxjs';
|
||||
import { Link } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/entities';
|
||||
import { DucClient } from '../duc/client.config';
|
||||
import { ClientGrpc } from '@nestjs/microservices';
|
||||
import { ProtoServices } from '@dadosfera/protospack-v2/dist/lib/Duc';
|
||||
import { CustomerSetLinksRequest } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages';
|
||||
import { CustomerUpdateRequest } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages';
|
||||
import { CustomersProtoService } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service';
|
||||
import { CustomerLinksConfig } from './dtos/customers';
|
||||
import ErrorCodes from 'src/utils/errorCodes';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
@@ -67,12 +67,12 @@ export class CustomersService implements OnModuleInit {
|
||||
)
|
||||
}
|
||||
|
||||
async getLinks(customerId: string): Promise<CustomerLinksConfig | null> {
|
||||
async getLinks(customerId: string) {
|
||||
try {
|
||||
const result = await lastValueFrom(
|
||||
this.customerService.CustomerGetLinks({ customerId }),
|
||||
this.customerService.CustomerFindOneById({ id: customerId }),
|
||||
);
|
||||
return (result.links as CustomerLinksConfig) || null;
|
||||
return result.customer?.links || [];
|
||||
} catch (err) {
|
||||
if (err.details === ErrorCodes.CUSTOMER.NOT_FOUND)
|
||||
throw new HttpException(err.details, HttpStatus.NOT_FOUND);
|
||||
@@ -80,17 +80,17 @@ export class CustomersService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async setLinks(customerId: string, links: CustomerLinksConfig) {
|
||||
async setLinks(customerId: string, links: Link[]) {
|
||||
if (!customerId || !links) {
|
||||
throw new HttpException(null, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
return await firstValueFrom(
|
||||
this.customerService.CustomerSetLinks({
|
||||
customerId,
|
||||
links: links as CustomerSetLinksRequest['links'],
|
||||
}),
|
||||
this.customerService.CustomerUpdate({
|
||||
id: customerId,
|
||||
links,
|
||||
} as CustomerUpdateRequest),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.details === ErrorCodes.CUSTOMER.NOT_FOUND)
|
||||
@@ -223,57 +223,4 @@ export class CustomersService implements OnModuleInit {
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async updateOrganizationInfo(
|
||||
customerId: string,
|
||||
data: {
|
||||
companyName: string;
|
||||
companySite: string;
|
||||
domain: string;
|
||||
cnpj: string;
|
||||
description: string;
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const result = await lastValueFrom(
|
||||
this.customerService.OrganizationUpdate({
|
||||
customerId,
|
||||
companyName: data.companyName || '',
|
||||
companySite: data.companySite || '',
|
||||
domain: data.domain || '',
|
||||
cnpj: data.cnpj || '',
|
||||
description: data.description || '',
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err.details === ErrorCodes.CUSTOMER.NOT_FOUND)
|
||||
throw new HttpException(err.details, HttpStatus.NOT_FOUND);
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getOrganizationInfo(customerId: string) {
|
||||
try {
|
||||
const customerResponse = await lastValueFrom(
|
||||
this.customerService.CustomerFindOneById({ id: customerId })
|
||||
);
|
||||
|
||||
const customer = customerResponse.customer;
|
||||
|
||||
return {
|
||||
companyName: customer.companyName || '',
|
||||
companySite: customer.companySite || '',
|
||||
domain: customer.domain || '',
|
||||
cnpj: customer.cnpj || '',
|
||||
description: customer.description || ''
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.details === ErrorCodes.CUSTOMER.NOT_FOUND)
|
||||
throw new HttpException(err.details, HttpStatus.NOT_FOUND);
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/entities';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CustomerLinkItem {
|
||||
export class CustomerLink implements Link {
|
||||
@ApiProperty()
|
||||
href: string;
|
||||
@ApiProperty()
|
||||
@@ -8,59 +9,15 @@ export class CustomerLinkItem {
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
@ApiPropertyOptional()
|
||||
iconSrc?: string;
|
||||
iconSrc: string;
|
||||
}
|
||||
|
||||
export class CustomerSidebarLinkItem {
|
||||
@ApiProperty()
|
||||
type: 'link';
|
||||
@ApiProperty({ type: Object })
|
||||
title: Record<string, string>;
|
||||
@ApiProperty()
|
||||
link: string;
|
||||
@ApiPropertyOptional()
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export class CustomerSidebarMenuItem {
|
||||
@ApiProperty()
|
||||
type: 'menu';
|
||||
@ApiProperty({ type: Object })
|
||||
title: Record<string, string>;
|
||||
@ApiPropertyOptional()
|
||||
icon?: string;
|
||||
@ApiProperty({ type: [CustomerSidebarLinkItem] })
|
||||
items: CustomerSidebarLinkItem[];
|
||||
}
|
||||
|
||||
export class CustomerSidebarSection {
|
||||
@ApiProperty({ type: Object })
|
||||
title: Record<string, string>;
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: '#/components/schemas/CustomerSidebarMenuItem' },
|
||||
{ $ref: '#/components/schemas/CustomerSidebarLinkItem' },
|
||||
],
|
||||
},
|
||||
})
|
||||
items: (CustomerSidebarMenuItem | CustomerSidebarLinkItem)[];
|
||||
}
|
||||
|
||||
export class CustomerLinksConfig {
|
||||
@ApiPropertyOptional({ type: [CustomerLinkItem] })
|
||||
home?: CustomerLinkItem[];
|
||||
@ApiPropertyOptional({ type: [CustomerSidebarSection] })
|
||||
sidebar?: CustomerSidebarSection[];
|
||||
}
|
||||
|
||||
export class CustomerLinkRequest {
|
||||
@ApiProperty({ type: CustomerLinksConfig })
|
||||
links: CustomerLinksConfig;
|
||||
@ApiProperty({ type: [CustomerLink] })
|
||||
links: CustomerLink[];
|
||||
}
|
||||
|
||||
export class CustomerLinksResponse {
|
||||
@ApiPropertyOptional({ type: CustomerLinksConfig })
|
||||
links?: CustomerLinksConfig;
|
||||
}
|
||||
@ApiProperty({ type: [CustomerLink] })
|
||||
links: CustomerLink[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class OrganizationUpdateRequest {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@ApiPropertyOptional()
|
||||
companySite: string;
|
||||
@ApiProperty()
|
||||
domain: string;
|
||||
@ApiPropertyOptional()
|
||||
info: string;
|
||||
@ApiPropertyOptional()
|
||||
cnpj: string;
|
||||
}
|
||||
|
||||
export class OrganizationResponse {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@ApiPropertyOptional()
|
||||
companySite: string;
|
||||
@ApiProperty()
|
||||
domain: string;
|
||||
@ApiPropertyOptional()
|
||||
info: string;
|
||||
@ApiPropertyOptional()
|
||||
cnpj: string;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import {
|
||||
CdcColumn,
|
||||
CdcTable,
|
||||
} from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
|
||||
|
||||
/** What a caller knows about a CDC table before it is stored. The source
|
||||
* table is named `table_name` on the platform-facing bodies and `name` on
|
||||
* the create DTO (CdcTableReq); either works — deriving one from the other
|
||||
* happens only here. */
|
||||
export interface CdcTableInput {
|
||||
// Optional on the create DTO (CdcTableReq); the platform validates it.
|
||||
table_schema?: string;
|
||||
table_name?: string;
|
||||
name?: string;
|
||||
primary_keys?: string[];
|
||||
iceberg_table_name?: string;
|
||||
iceberg_qualify_table_name?: string;
|
||||
columns?: CdcColumn[];
|
||||
column_exclude_list?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The only place maestro builds a protospack `CdcTable`.
|
||||
*
|
||||
* `name` is the identity key shared with batch `NewTable` (in-factory keys
|
||||
* update/soft-delete on it) and the proto keeps it required, so it mirrors
|
||||
* `table_name` here and nowhere else. Follow-up (protospack): make `name`
|
||||
* optional and let in-factory be its sole writer (it already backfills
|
||||
* `name ?? table_name`).
|
||||
*/
|
||||
export function toCdcTable(table: CdcTableInput): CdcTable {
|
||||
const tableName = table.table_name ?? table.name;
|
||||
return {
|
||||
table_schema: table.table_schema,
|
||||
table_name: tableName,
|
||||
name: tableName,
|
||||
primary_keys: table.primary_keys ?? [],
|
||||
iceberg_table_name: table.iceberg_table_name,
|
||||
iceberg_qualify_table_name: table.iceberg_qualify_table_name,
|
||||
columns: table.columns ?? [],
|
||||
column_exclude_list: table.column_exclude_list ?? [],
|
||||
};
|
||||
}
|
||||
@@ -11,19 +11,10 @@ export class TableColumns {
|
||||
name: string;
|
||||
@ApiProperty()
|
||||
columns: string[];
|
||||
@ApiPropertyOptional({ type: [Column] })
|
||||
@ApiProperty()
|
||||
references: Column[];
|
||||
@ApiProperty()
|
||||
destination: Record<'raw' | 'qualify', {
|
||||
table_name: string;
|
||||
table_schema: string;
|
||||
}> | null;
|
||||
@ApiProperty()
|
||||
type: string;
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
identifier_columns?: string[];
|
||||
@ApiPropertyOptional({ type: Column })
|
||||
reference_column?: Column;
|
||||
}
|
||||
export class AvailableEntity {
|
||||
@ApiProperty()
|
||||
@@ -65,64 +56,3 @@ export class CreateInputReq extends OmitType(Input, [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]) {}
|
||||
|
||||
export class CdcColumnReq {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@ApiProperty()
|
||||
type: string;
|
||||
@ApiProperty()
|
||||
is_primary_key: boolean;
|
||||
}
|
||||
|
||||
export class CdcTableReq {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
// Source database/schema. Required: Debezium addresses tables as
|
||||
// `schema.table`, a CDC table without it cannot be replicated.
|
||||
@ApiProperty()
|
||||
table_schema: string;
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
primary_keys?: string[];
|
||||
// Per-table raw Iceberg table name override (iceberg destination only).
|
||||
// Honored on the create/add path: the platform lowercases + sanitizes it
|
||||
// authoritatively; empty/absent => the platform derives tb__<hash>__<table>.
|
||||
@ApiPropertyOptional()
|
||||
iceberg_table_name?: string;
|
||||
// Per-table deduped (qualify) Iceberg table name override (iceberg dest only).
|
||||
// Empty/absent => the deduped table takes the same name as the raw table.
|
||||
@ApiPropertyOptional()
|
||||
iceberg_qualify_table_name?: string;
|
||||
@ApiPropertyOptional({ type: [CdcColumnReq] })
|
||||
columns?: CdcColumnReq[];
|
||||
// Columns the user chose to ignore -> Debezium column.exclude.list.
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
column_exclude_list?: string[];
|
||||
}
|
||||
|
||||
export class IcebergDestinationReq {
|
||||
@ApiProperty()
|
||||
namespace: string;
|
||||
// Pipeline-wide deduped (qualify) namespace. Absent => the platform derives
|
||||
// the sibling of `namespace` (cdc_raw -> cdc_dedup).
|
||||
@ApiPropertyOptional()
|
||||
qualify_namespace?: string;
|
||||
}
|
||||
|
||||
export class CdcDestinationReq {
|
||||
@ApiPropertyOptional({ type: IcebergDestinationReq })
|
||||
iceberg?: IcebergDestinationReq;
|
||||
}
|
||||
|
||||
export class CreateCdcInputReq {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@ApiProperty()
|
||||
plugin: string; // mysql_cdc (v1)
|
||||
@ApiProperty({ type: [CdcTableReq] })
|
||||
tables: CdcTableReq[];
|
||||
@ApiPropertyOptional()
|
||||
read_only?: boolean;
|
||||
@ApiPropertyOptional({ type: CdcDestinationReq })
|
||||
destination?: CdcDestinationReq;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export interface Info {
|
||||
user_id: string;
|
||||
customer_id: string;
|
||||
customer: string;
|
||||
}
|
||||
import { Info } from '@dadosfera/protospack/dist/lib/interfaces';
|
||||
|
||||
interface Values {
|
||||
jdbc_user: string;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { InputsController } from './inputs.controller';
|
||||
import { InputsService } from './inputs.service';
|
||||
import DadosferaLogger from '@dadosfera/dadosfera-logs';
|
||||
import { CreateCdcInputReq } from './dtos/input.model';
|
||||
import { RequestUser } from 'src/decorators/user.decorator';
|
||||
|
||||
describe('InputsController', () => {
|
||||
let controller: InputsController;
|
||||
let inputsService: { createCdc: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
inputsService = {
|
||||
createCdc: jest.fn().mockResolvedValue({ input: {} }),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [InputsController],
|
||||
providers: [
|
||||
{
|
||||
provide: DadosferaLogger,
|
||||
useValue: { logger: { info: jest.fn() } },
|
||||
},
|
||||
{
|
||||
provide: InputsService,
|
||||
useValue: inputsService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<InputsController>(InputsController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('forwards destination.iceberg.namespace to InputsService.createCdc', async () => {
|
||||
const body: CreateCdcInputReq = {
|
||||
name: 'my-cdc-input',
|
||||
plugin: 'mysql_cdc',
|
||||
tables: [
|
||||
{
|
||||
name: 'orders',
|
||||
table_schema: 'public',
|
||||
iceberg_table_name: 'orders_iceberg',
|
||||
},
|
||||
],
|
||||
destination: {
|
||||
iceberg: {
|
||||
namespace: 'my_namespace',
|
||||
},
|
||||
},
|
||||
};
|
||||
const user: RequestUser = {
|
||||
user_id: 'user-1',
|
||||
customer_id: 'customer-1',
|
||||
customer_name: 'customer',
|
||||
} as RequestUser;
|
||||
|
||||
await controller.createCdc(body, user);
|
||||
|
||||
expect(inputsService.createCdc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
destination: {
|
||||
iceberg: {
|
||||
namespace: 'my_namespace',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ import { PERMISSIONS_GROUPS } from '../../authentication/permissions.enum';
|
||||
import { AuthenticateCondition } from 'src/decorators/authentication.decorator';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
CreateCdcInputReq,
|
||||
CreateInputReq,
|
||||
GetAvailableEntitiesReq,
|
||||
GetAvailableEntitiesRes,
|
||||
@@ -100,32 +99,11 @@ export class InputsController {
|
||||
customer: info.customer,
|
||||
});
|
||||
|
||||
this.logger.info(JSON.stringify(body))
|
||||
const response = await this.inputService.create({ body, info });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@Post('cdc')
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@ApiOkResponse({ type: Input })
|
||||
async createCdc(
|
||||
@Body() body: CreateCdcInputReq,
|
||||
@User() user: RequestUser,
|
||||
) {
|
||||
const info: Info = {
|
||||
user_id: user.user_id,
|
||||
customer: user.customer_name,
|
||||
customer_id: user.customer_id,
|
||||
};
|
||||
this.logger.info(`/inputs/cdc - ON CREATE CDC INPUT ROUTE`, {
|
||||
user: info.user_id,
|
||||
customer: info.customer,
|
||||
});
|
||||
|
||||
return this.inputService.createCdc({ body, info });
|
||||
}
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Get()
|
||||
async findAll(@User() user: RequestUser) {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { of } from 'rxjs';
|
||||
import { InputsService } from './inputs.service';
|
||||
import DadosferaLogger from '@dadosfera/dadosfera-logs/dist';
|
||||
import { CreateCdcInputReq } from './dtos/input.model';
|
||||
import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
|
||||
|
||||
const info = { customer_id: 'cid', user_id: 'u' } as unknown as Info;
|
||||
|
||||
describe('InputsService.createCdc', () => {
|
||||
let service: InputsService;
|
||||
let inputCreateCdcMock: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
inputCreateCdcMock = jest
|
||||
.fn()
|
||||
.mockImplementation((req) => of({ input: req.input }));
|
||||
|
||||
const grpcClient: any = {
|
||||
getService: jest.fn().mockReturnValue({
|
||||
InputCreateCdc: inputCreateCdcMock,
|
||||
}),
|
||||
};
|
||||
|
||||
service = new InputsService(new DadosferaLogger(), grpcClient);
|
||||
await service.onModuleInit();
|
||||
});
|
||||
|
||||
it('forwards destination and per-table iceberg_table_name to the gRPC request', async () => {
|
||||
const body: CreateCdcInputReq = {
|
||||
name: 'CDC Iceberg Test',
|
||||
plugin: 'mysql_cdc',
|
||||
read_only: true,
|
||||
destination: { iceberg: { namespace: 'cdc_raw' } },
|
||||
tables: [
|
||||
{
|
||||
name: 'orders',
|
||||
table_schema: 'mydb',
|
||||
primary_keys: ['id'],
|
||||
iceberg_table_name: 'cdc_raw.mydb__orders',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await service.createCdc({ body, info });
|
||||
|
||||
expect(inputCreateCdcMock).toHaveBeenCalledTimes(1);
|
||||
const sentRequest = inputCreateCdcMock.mock.calls[0][0];
|
||||
|
||||
expect(sentRequest.input).toEqual(
|
||||
expect.objectContaining({
|
||||
destination: { iceberg: { namespace: 'cdc_raw' } },
|
||||
}),
|
||||
);
|
||||
expect(sentRequest.input.tables[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
iceberg_table_name: 'cdc_raw.mydb__orders',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards per-table columns to the gRPC request', async () => {
|
||||
const body: CreateCdcInputReq = {
|
||||
name: 'CDC Columns Test',
|
||||
plugin: 'mysql_cdc',
|
||||
read_only: true,
|
||||
tables: [
|
||||
{
|
||||
name: 'orders',
|
||||
table_schema: 'mydb',
|
||||
primary_keys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'int', is_primary_key: true },
|
||||
{ name: 'descr', type: 'varchar(255)', is_primary_key: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await service.createCdc({ body, info });
|
||||
|
||||
expect(inputCreateCdcMock).toHaveBeenCalledTimes(1);
|
||||
const sentRequest = inputCreateCdcMock.mock.calls[0][0];
|
||||
|
||||
expect(sentRequest.input.tables[0].columns).toEqual([
|
||||
{ name: 'id', type: 'int', is_primary_key: true },
|
||||
{ name: 'descr', type: 'varchar(255)', is_primary_key: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('back-compat: a body with no destination sends destination undefined, not an error', async () => {
|
||||
const body: CreateCdcInputReq = {
|
||||
name: 'CDC Legacy Test',
|
||||
plugin: 'mysql_cdc',
|
||||
read_only: true,
|
||||
tables: [
|
||||
{
|
||||
name: 'pedidos',
|
||||
table_schema: 'cadastros',
|
||||
primary_keys: ['id'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.createCdc({ body, info });
|
||||
|
||||
expect(inputCreateCdcMock).toHaveBeenCalledTimes(1);
|
||||
const sentRequest = inputCreateCdcMock.mock.calls[0][0];
|
||||
|
||||
expect(sentRequest.input.destination).toBeUndefined();
|
||||
expect(sentRequest.input.tables[0].iceberg_table_name).toBeUndefined();
|
||||
expect(result.input).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -13,22 +13,14 @@ import { objectCamelToSnake } from 'src/utils/CaseConverter';
|
||||
import { IIdRequest, UpdateInputRequest } from './dtos/old_interfaces';
|
||||
import { Input } from '@dadosfera/protospack-v2';
|
||||
import {
|
||||
AddCdcTableRequest,
|
||||
GetAvailableEntitiesRequest,
|
||||
InputCreateGenericRequest,
|
||||
InputCreateCdcRequest,
|
||||
InputCreateS3Request,
|
||||
InputNewCreateRequest,
|
||||
InputUpdateResponse,
|
||||
MarkTableDeletedRequest,
|
||||
RemoveCdcTableRequest,
|
||||
RollbackInputRequest,
|
||||
TestConnectionRequest,
|
||||
} from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/messages';
|
||||
import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
|
||||
import { CreateCdcInputReq, CreateInputReq } from './dtos/input.model';
|
||||
import { toCdcTable } from './cdc-table.mapper';
|
||||
import { Metadata } from '@grpc/grpc-js';
|
||||
import { CreateInputReq } from './dtos/input.model';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -79,10 +71,10 @@ export class InputsService {
|
||||
objectCamelToSnake(createInputResponse);
|
||||
return createInputResponse;
|
||||
},
|
||||
update: async (updateInputDTO: UpdateInputRequest, metadata: Metadata): Promise<InputUpdateResponse> => {
|
||||
this.logger.info('InputClientService - Update' + JSON.stringify(updateInputDTO));
|
||||
update: async (updateInputDTO: UpdateInputRequest) => {
|
||||
this.logger.info('InputClientService - Update');
|
||||
const updateInputResponse = await lastValueFrom(
|
||||
this.inputWriteService.InputUpdate(updateInputDTO, metadata),
|
||||
this.inputWriteService.InputUpdate(updateInputDTO),
|
||||
);
|
||||
|
||||
return updateInputResponse;
|
||||
@@ -172,11 +164,6 @@ export class InputsService {
|
||||
const inputCreateGenericRequest: InputCreateGenericRequest = {
|
||||
input: {
|
||||
...body,
|
||||
tables: (body.tables || []).map((table) => ({
|
||||
...table,
|
||||
identifier_columns: table.identifier_columns || [],
|
||||
reference_column: table.reference_column || table.references?.[0],
|
||||
})),
|
||||
},
|
||||
info,
|
||||
};
|
||||
@@ -188,26 +175,6 @@ export class InputsService {
|
||||
return { input: adjustedInput };
|
||||
}
|
||||
|
||||
async createCdc(data: { body: CreateCdcInputReq; info: Info }) {
|
||||
const { body, info } = data;
|
||||
|
||||
const inputCreateCdcRequest: InputCreateCdcRequest = {
|
||||
input: {
|
||||
name: body.name,
|
||||
plugin: body.plugin,
|
||||
read_only: body.read_only ?? true,
|
||||
tables: body.tables.map(toCdcTable),
|
||||
destination: body.destination,
|
||||
},
|
||||
info,
|
||||
};
|
||||
|
||||
const { input } = await lastValueFrom(
|
||||
this.inputWriteService.InputCreateCdc(inputCreateCdcRequest),
|
||||
);
|
||||
return { input };
|
||||
}
|
||||
|
||||
async getAvailableEntities(data: GetAvailableEntitiesRequest) {
|
||||
return lastValueFrom(this.inputReadService.GetAvailableEntities(data));
|
||||
}
|
||||
@@ -232,47 +199,24 @@ export class InputsService {
|
||||
return findOneInputResponse;
|
||||
}
|
||||
|
||||
async update(id: string, data, info: Info, metadata?: Metadata) {
|
||||
// this.validateCron({ ...data, info });
|
||||
async update(id: string, data, info: Info) {
|
||||
this.validateCron({ ...data, info });
|
||||
try {
|
||||
const {
|
||||
tablesUpdate,
|
||||
dataAssetUpdate,
|
||||
input
|
||||
} = await this.OLD_inputClient.update({
|
||||
const updateInputResponse: any = await this.OLD_inputClient.update({
|
||||
id,
|
||||
...data,
|
||||
info,
|
||||
}, metadata);
|
||||
...data,
|
||||
});
|
||||
|
||||
const updateInputResponse = this.adjustInputPayload(
|
||||
input,
|
||||
updateInputResponse.input = this.adjustInputPayload(
|
||||
updateInputResponse?.input,
|
||||
);
|
||||
return {
|
||||
input: updateInputResponse,
|
||||
tablesUpdate,
|
||||
dataAssetUpdate
|
||||
};
|
||||
return updateInputResponse;
|
||||
} catch (err) {
|
||||
throw new HttpException(err.message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
async rollbackUpdate(
|
||||
data: RollbackInputRequest
|
||||
) {
|
||||
this.logger.info('PipelinesClientService - rollbackUpdate');
|
||||
this.logger.info('Rolling back input update with data: ' + JSON.stringify(data));
|
||||
const updatePipelineResponse = await lastValueFrom(
|
||||
this.inputWriteService.RollbackInputUpdate(
|
||||
data
|
||||
),
|
||||
);
|
||||
this.logger.info('Done');
|
||||
|
||||
return updatePipelineResponse;
|
||||
}
|
||||
|
||||
async remove(idRequest: IIdRequest) {
|
||||
return lastValueFrom(this.inputWriteService.InputRemove(idRequest));
|
||||
}
|
||||
@@ -314,20 +258,4 @@ export class InputsService {
|
||||
};
|
||||
return formatedPayload;
|
||||
}
|
||||
|
||||
async markTableDeleted(data: MarkTableDeletedRequest) {
|
||||
return lastValueFrom(this.inputWriteService.MarkTableDeleted(data));
|
||||
}
|
||||
|
||||
async unmarkTableDeleted(data: MarkTableDeletedRequest) {
|
||||
return lastValueFrom(this.inputWriteService.UnmarkTableDeleted(data));
|
||||
}
|
||||
|
||||
async addCdcTable(data: AddCdcTableRequest) {
|
||||
return lastValueFrom(this.inputWriteService.AddCdcTable(data));
|
||||
}
|
||||
|
||||
async removeCdcTable(data: RemoveCdcTableRequest) {
|
||||
return lastValueFrom(this.inputWriteService.RemoveCdcTable(data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common';
|
||||
import { init } from 'mixpanel';
|
||||
import { Authenticated } from 'src/decorators/authentication.decorator';
|
||||
import { ApiInternalOnlyController } from 'src/decorators/swagger.decorator';
|
||||
import { RequestUser } from 'src/decorators/user.decorator';
|
||||
import { RequestUser, User } from 'src/decorators/user.decorator';
|
||||
import { MixpanelService } from './mixpanel.service';
|
||||
import { extractUserFrom } from 'src/authentication/extract-user';
|
||||
import DadosferaLogger from '@dadosfera/dadosfera-logs';
|
||||
|
||||
@ApiInternalOnlyController()
|
||||
@Controller('trackEvent')
|
||||
export class MixpanelController {
|
||||
logger: DadosferaLogger;
|
||||
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
private mixpanelService: MixpanelService,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
private mixpanelService: MixpanelService
|
||||
) {}
|
||||
@Post(':id')
|
||||
async trackEvent(
|
||||
@Param('id') id,
|
||||
@Body() body,
|
||||
@User() user: RequestUser,
|
||||
@Req() request
|
||||
) {
|
||||
this.logger.info(`POST Track Event: ${id}`)
|
||||
delete body.info;
|
||||
|
||||
const anonymousUser = {
|
||||
username: "anonymous",
|
||||
customer_name: "anonymous"
|
||||
} as RequestUser
|
||||
|
||||
const hasToken = request.headers['authorization'];
|
||||
|
||||
const user = hasToken ? extractUserFrom(hasToken) : anonymousUser;
|
||||
|
||||
this.logger.info(`Has user: ${typeof hasToken == "string"}`)
|
||||
|
||||
await this.mixpanelService.track(id, user, request, body)
|
||||
|
||||
this.logger.info(`Event successful`)
|
||||
return { id, body, user: user.username };
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ConflictException, Inject, OnModuleInit } from '@nestjs/common';
|
||||
import { ClientGrpc } from '@nestjs/microservices';
|
||||
import {
|
||||
PipelineServicesNames,
|
||||
PipelinesServiceInterface,
|
||||
} from '@dadosfera/protospack';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { IIdRequest } from './interfaces';
|
||||
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { PipelinesClientConfiguration } from './pipelines-client';
|
||||
|
||||
export class PipelinesClientService implements OnModuleInit {
|
||||
private pipelineService: PipelinesServiceInterface;
|
||||
logger: DadosferaLogger;
|
||||
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
@Inject(PipelinesClientConfiguration.name)
|
||||
private readonly grpcClient: ClientGrpc,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.pipelineService =
|
||||
this.grpcClient.getService<PipelinesServiceInterface>(
|
||||
PipelineServicesNames.PipelineService,
|
||||
);
|
||||
}
|
||||
|
||||
async getPipelineStatus(data) {
|
||||
this.logger.info('PipelinesClientService - GetPipelineStatus');
|
||||
|
||||
const statusPipelineResponse = await lastValueFrom(
|
||||
this.pipelineService.getPipelineStatus(data),
|
||||
)
|
||||
.then((res) => {
|
||||
const statusArray =
|
||||
res.status?.sort((a, b) => {
|
||||
if (a.id < b.id) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}) || [];
|
||||
return { status: statusArray };
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error(err.message);
|
||||
throw new Error(err);
|
||||
});
|
||||
this.logger.info('Done');
|
||||
|
||||
return statusPipelineResponse;
|
||||
}
|
||||
|
||||
async runPipeline({ id, info }: IIdRequest) {
|
||||
this.logger.info('PipelinesClientService - RunPipeline');
|
||||
const statusPipelineResponse = await lastValueFrom(
|
||||
this.pipelineService.triggerPipeline({ id, info }),
|
||||
).catch((err) => {
|
||||
this.logger.error(err.message);
|
||||
throw new Error(err);
|
||||
});
|
||||
|
||||
if (statusPipelineResponse.status == false) {
|
||||
throw new ConflictException(
|
||||
'This pipeline is not ready yet to execute, Try again later!',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info('Done');
|
||||
return statusPipelineResponse;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Info } from '@dadosfera/protospack/dist/lib/interfaces';
|
||||
|
||||
export interface ICreatePipelineDto {
|
||||
input: IdRequest;
|
||||
transformations: IdRequest[];
|
||||
output: IdRequest;
|
||||
tags: string[];
|
||||
name: string;
|
||||
description: string;
|
||||
info: Info;
|
||||
}
|
||||
|
||||
export interface IdRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IIdRequest {
|
||||
id: string;
|
||||
info: Info;
|
||||
}
|
||||
|
||||
export interface IUpdatePipelineRequest {
|
||||
input: IdRequest;
|
||||
transformations: IdRequest[];
|
||||
output: IdRequest;
|
||||
tags: string[];
|
||||
name: string;
|
||||
description: string;
|
||||
id: string;
|
||||
info: Info;
|
||||
}
|
||||
|
||||
export interface IGetPipelineLogsRequest {
|
||||
id: string;
|
||||
details: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
ClientsProviderAsyncOptions,
|
||||
GrpcOptions,
|
||||
Transport,
|
||||
} from '@nestjs/microservices';
|
||||
import { PipelinePackages, PipelineProtoFilePath } from '@dadosfera/protospack';
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
|
||||
const isLocalConnection =
|
||||
process.env.PIFACTORY_URL.startsWith('pi-factory:') ||
|
||||
process.env.PIFACTORY_URL.includes('0.0.0.0');
|
||||
|
||||
export class PipelinesClientConfiguration {
|
||||
public name = 'PipelinesClientConfiguration';
|
||||
private config: GrpcOptions = {
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
url: process.env.PIFACTORY_URL,
|
||||
package: PipelinePackages,
|
||||
credentials: isLocalConnection ? undefined : credentials.createSsl(),
|
||||
protoPath: PipelineProtoFilePath,
|
||||
loader: {
|
||||
keepCase: true,
|
||||
enums: String,
|
||||
defaults: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
providerOptions: ClientsProviderAsyncOptions = {
|
||||
name: this.name,
|
||||
...this.config,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Body, Controller, Get, Inject, Param, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
AuthenticateCondition,
|
||||
Authenticated,
|
||||
} from 'src/decorators/authentication.decorator';
|
||||
import { PERMISSIONS_GROUPS } from '../../authentication/permissions.enum';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { ApiInternalOnlyController } from 'src/decorators/swagger.decorator';
|
||||
|
||||
@ApiInternalOnlyController()
|
||||
@ApiTags('Pipelines')
|
||||
@Controller('pipelines')
|
||||
@Authenticated()
|
||||
@AuthenticateCondition((req, user) => {
|
||||
let action;
|
||||
|
||||
switch (req.method) {
|
||||
case 'POST':
|
||||
action = 'CREATE';
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
action = 'UPDATE';
|
||||
break;
|
||||
|
||||
default:
|
||||
action = req.method;
|
||||
}
|
||||
|
||||
return user.permissions.includes(
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions[action].seqid,
|
||||
);
|
||||
})
|
||||
export class PipelinesController {
|
||||
logger: DadosferaLogger;
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
private pipelineService: PipelinesService,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
@Post('start/:id')
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
description:
|
||||
'This method is deprecated. Please use route /pipelinesV2/start/:id instead',
|
||||
})
|
||||
async activate(@Param('id') id: string, @Body() body) {
|
||||
const { info } = body;
|
||||
|
||||
this.logger.info(
|
||||
process.env.DEV_URL + `/pipeline/start/${id} - ON START PIPELINE ROUTE`,
|
||||
{
|
||||
user: body.info.user_id,
|
||||
customer: body.info.customer,
|
||||
},
|
||||
);
|
||||
|
||||
const response = await this.pipelineService.runPipeline({ id, info });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@Get(':id/status')
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
description:
|
||||
'This method is deprecated. Please use route /pipelinesV2/:id/status instead',
|
||||
})
|
||||
async getPipelineStatus(@Body() body, @Param('id') id: string) {
|
||||
body.id = id;
|
||||
|
||||
this.logger.info(
|
||||
process.env.DEV_URL + `/pipeline/${id} - ON GET PIPELINE STATUS ROUTE`,
|
||||
{
|
||||
user: body.info.user_id,
|
||||
customer: body.info.customer,
|
||||
},
|
||||
);
|
||||
|
||||
const response = await this.pipelineService.getPipelineStatus(body);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { PipelinesController } from './pipelines.controller';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
import { PipelinesClientConfiguration } from './pipelines-client';
|
||||
import { PipelinesClientService } from './client.service';
|
||||
|
||||
const client = new PipelinesClientConfiguration();
|
||||
|
||||
@Module({
|
||||
imports: [ClientsModule.register([client.providerOptions])],
|
||||
controllers: [PipelinesController],
|
||||
providers: [PipelinesService, PipelinesClientService, DadosferaLogger],
|
||||
exports: [PipelinesService],
|
||||
})
|
||||
export class PipelinesModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PipelinesClientService } from './client.service';
|
||||
import { IIdRequest } from './interfaces';
|
||||
import { objectCamelToSnake } from 'src/utils/CaseConverter';
|
||||
|
||||
@Injectable()
|
||||
export class PipelinesService {
|
||||
constructor(private pipelineClient: PipelinesClientService) {}
|
||||
|
||||
async getPipelineStatus(data: IIdRequest) {
|
||||
try {
|
||||
const pipelineStatusResponse =
|
||||
await this.pipelineClient.getPipelineStatus(data);
|
||||
|
||||
return objectCamelToSnake(pipelineStatusResponse);
|
||||
} catch (err) {
|
||||
throw new HttpException(err.message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
async runPipeline({ id, info }: IIdRequest) {
|
||||
try {
|
||||
const triggerPipelineResponse = await this.pipelineClient.runPipeline({
|
||||
id,
|
||||
info,
|
||||
});
|
||||
|
||||
return objectCamelToSnake(triggerPipelineResponse);
|
||||
} catch (err) {
|
||||
throw new HttpException(err.message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
|
||||
export class PipelineInputsDTO {
|
||||
@ApiProperty()
|
||||
tables: Array<{
|
||||
name: string,
|
||||
type: string,
|
||||
|
||||
}>
|
||||
}
|
||||
|
||||
export class PipelineTableDestination {
|
||||
@ApiPropertyOptional()
|
||||
table_schema?: string;
|
||||
@ApiPropertyOptional()
|
||||
table_name?: string;
|
||||
}
|
||||
|
||||
/** One entry of the create body's `config.tables`. Mirrors the frontend's
|
||||
* EntityWithColumnsNames; only the fields maestro/pi-factory read are typed,
|
||||
* the rest travels as-is. */
|
||||
export class PipelineTableConfig {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@ApiPropertyOptional({ type: () => PipelineTableDestination })
|
||||
destinations?: Partial<Record<'raw' | 'qualify', PipelineTableDestination>>;
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** `{ cron, tables }` as sent by the frontend on create; JSON-serialized onto
|
||||
* the gRPC `config` string field. */
|
||||
export class PipelineConfig {
|
||||
@ApiPropertyOptional()
|
||||
cron?: string;
|
||||
@ApiPropertyOptional({ type: [PipelineTableConfig] })
|
||||
tables?: PipelineTableConfig[];
|
||||
}
|
||||
import { Info } from '@dadosfera/protospack/dist/lib/interfaces';
|
||||
|
||||
export class IPipelineV2 {
|
||||
@ApiProperty()
|
||||
@@ -58,8 +23,6 @@ export class IPipelineV2 {
|
||||
tags?: string[];
|
||||
@ApiPropertyOptional()
|
||||
properties?: any;
|
||||
@ApiPropertyOptional({ type: () => PipelineConfig })
|
||||
config?: PipelineConfig;
|
||||
|
||||
@ApiProperty()
|
||||
connector_name: string;
|
||||
@@ -90,12 +53,6 @@ export interface IIdRequest {
|
||||
info: Info;
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
user_id: string;
|
||||
customer_id: string;
|
||||
customer: string;
|
||||
}
|
||||
|
||||
export interface IUpdatePipelineRequest {
|
||||
input: IdRequest;
|
||||
transformations: IdRequest[];
|
||||
@@ -166,30 +123,3 @@ export class PipelineFindAllReq {
|
||||
@ApiPropertyOptional()
|
||||
type?: string | undefined;
|
||||
}
|
||||
|
||||
export interface UpdateTableDTO {
|
||||
name: string;
|
||||
type: string;
|
||||
columns: string[];
|
||||
destinations: {
|
||||
raw: {
|
||||
table_schema: string;
|
||||
table_name: string;
|
||||
};
|
||||
qualify: {
|
||||
table_schema: string;
|
||||
table_name: string;
|
||||
};
|
||||
};
|
||||
identifier_columns: string[];
|
||||
reference_column: {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
memory: number;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformInputRequest {
|
||||
cron: string;
|
||||
tables: Array<UpdateTableDTO>;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ export class PipelinesClientConfiguration {
|
||||
loader: {
|
||||
keepCase: true,
|
||||
enums: String,
|
||||
// int64 fields (PipelineV2SinkTableOffset.committed_offset) decode as
|
||||
// plain JS numbers instead of Long.js objects, so they serialize as
|
||||
// JSON numbers for the frontend. Safe: Kafka offsets fit in 2^53.
|
||||
longs: Number,
|
||||
objects: true,
|
||||
arrays: true,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Patch,
|
||||
HttpException,
|
||||
BadRequestException,
|
||||
UseGuards,
|
||||
CacheTTL,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
@@ -24,17 +24,17 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
AuthenticateCondition,
|
||||
RequireAllPermissions,
|
||||
RequireModule,
|
||||
RequireSomePermission,
|
||||
} from 'src/decorators/authentication.decorator';
|
||||
import { DADOSFERA_MODULES_KEYS, PERMISSIONS_GROUPS } from '../../authentication/permissions.enum';
|
||||
import { PERMISSIONS_GROUPS } from '../../authentication/permissions.enum';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { Messages } from '@dadosfera/protospack-v2/dist/lib/PipelineV2';
|
||||
import { RequestUser, User } from 'src/decorators/user.decorator';
|
||||
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
|
||||
|
||||
import { PipelinesService as OldPipelineService } from 'src/modules/pipelines/pipelines.service';
|
||||
import {
|
||||
ICompleteUploadCSVFile,
|
||||
ICreatePipelineCSVFile,
|
||||
@@ -42,37 +42,52 @@ import {
|
||||
IPipelineV2,
|
||||
IInitUploadCSVFile,
|
||||
PipelineFindAllReq,
|
||||
UpdatePlatformInputRequest,
|
||||
} from './interfaces';
|
||||
import { GrpcToHttpExceptionFilter } from 'src/error/grpc-to-http-exception.filter';
|
||||
import { LanguageEnum } from 'src/utils/languages.enum';
|
||||
import { Language } from 'src/decorators/language.decorator';
|
||||
import { ApiInternalOnlyEndpoint } from 'src/decorators/swagger.decorator';
|
||||
import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
|
||||
import { PipelineExecutionGuard } from 'src/guards/pipeline-execution.guard';
|
||||
|
||||
type PipelineTable = { name: string; job_id?: string; is_deleted?: boolean; [key: string]: any };
|
||||
type PipelineTablesConfig = { input_id?: string; tables: PipelineTable[] };
|
||||
|
||||
@ApiTags('PipelinesV2')
|
||||
@ApiHeaders([{ name: 'dadosfera-lang', enum: LanguageEnum, required: false }])
|
||||
@UseFilters(new GrpcToHttpExceptionFilter())
|
||||
@Controller('pipelinesV2')
|
||||
@RequireModule(
|
||||
DADOSFERA_MODULES_KEYS.COLLECT
|
||||
)
|
||||
@AuthenticateCondition((req, user) => {
|
||||
let action;
|
||||
|
||||
switch (req.method) {
|
||||
case 'POST':
|
||||
action = 'CREATE';
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
action = 'UPDATE';
|
||||
break;
|
||||
|
||||
case 'PATCH':
|
||||
action = 'UPDATE';
|
||||
break;
|
||||
|
||||
default:
|
||||
action = req.method;
|
||||
}
|
||||
|
||||
return user.permissions.includes(
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions[action].seqid,
|
||||
);
|
||||
})
|
||||
export class PipelinesController {
|
||||
logger: DadosferaLogger;
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
private pipelinesClientService: PipelinesService,
|
||||
private oldPipelinesService: OldPipelineService,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
@Get('monitoring-dashboard')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async getMonitoringDashboard(@User() user: RequestUser) {
|
||||
this.logger.info('PipelinesController - getMonitoringDashboard', { user });
|
||||
|
||||
@@ -85,7 +100,6 @@ export class PipelinesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.CREATE)
|
||||
@ApiCreatedResponse({ type: IPipelineV2 })
|
||||
async create(
|
||||
@Language() language: LanguageEnum,
|
||||
@@ -112,7 +126,6 @@ export class PipelinesController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW, PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async findAll(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@@ -137,7 +150,6 @@ export class PipelinesController {
|
||||
}
|
||||
|
||||
@Get('/download-logs')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async downloadLogs(
|
||||
@User() user: RequestUser,
|
||||
@Language() language: LanguageEnum,
|
||||
@@ -168,7 +180,6 @@ export class PipelinesController {
|
||||
}
|
||||
|
||||
@Get(':id/config')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW,PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async getPipelineproperties(
|
||||
@Language() language: LanguageEnum,
|
||||
@User() user: RequestUser,
|
||||
@@ -180,7 +191,6 @@ export class PipelinesController {
|
||||
}
|
||||
|
||||
@Get(':id/objects')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async getPipelineObjects(
|
||||
@Language() language: LanguageEnum,
|
||||
@User() user: RequestUser,
|
||||
@@ -192,9 +202,7 @@ export class PipelinesController {
|
||||
}
|
||||
|
||||
@Get(':id/status')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW, PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async getPipelineStatus(@Body() body, @Param('id') id: string) {
|
||||
|
||||
body.id = id;
|
||||
|
||||
this.logger.info(`/pipeline/${id} - ON GET PIPELINE STATUS ROUTE`, {
|
||||
@@ -202,13 +210,12 @@ export class PipelinesController {
|
||||
customer: body.info.customer,
|
||||
});
|
||||
|
||||
const response = await this.pipelinesClientService.getPipelineStatus(body);
|
||||
const response = await this.oldPipelinesService.getPipelineStatus(body);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW, PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async findOne(
|
||||
@Language() language: LanguageEnum,
|
||||
@User() user: RequestUser,
|
||||
@@ -226,56 +233,31 @@ export class PipelinesController {
|
||||
language,
|
||||
});
|
||||
|
||||
const pipelineRes = await this.pipelinesClientService.findOne({ id }, metadata);
|
||||
|
||||
const parsed: PipelineTablesConfig = JSON.parse(pipelineRes.pipeline.config.tables);
|
||||
const input_id = parsed.input_id;
|
||||
const tables: PipelineTable[] = parsed.tables ?? [];
|
||||
|
||||
Object.assign(pipelineRes.pipeline, {
|
||||
transformations: pipelineRes.pipeline.transformations
|
||||
? JSON.parse(pipelineRes.pipeline.transformations)
|
||||
: [],
|
||||
config: {
|
||||
cron: pipelineRes.pipeline.config.cron,
|
||||
tables,
|
||||
input_id,
|
||||
},
|
||||
properties: pipelineRes.pipeline.properties
|
||||
? JSON.parse(pipelineRes.pipeline.properties)
|
||||
: {},
|
||||
});
|
||||
|
||||
return pipelineRes;
|
||||
}
|
||||
|
||||
@Get("/:id/data-assets")
|
||||
@RequireSomePermission(
|
||||
PERMISSIONS_GROUPS.PIPELINE.permissions.GET,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER,
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.GET
|
||||
)
|
||||
async findAllDataAssetByPipeline(
|
||||
@Language() language: LanguageEnum,
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
@Query('object') object: string
|
||||
) {
|
||||
const payload = {
|
||||
pipeline: id,
|
||||
object: object,
|
||||
};
|
||||
|
||||
this.logger.info(`GET pipelinesV2/:id/data-assets` + JSON.stringify(payload));
|
||||
|
||||
const result =
|
||||
await this.pipelinesClientService.findAllDataAssetByPipeline(payload, user);
|
||||
const result = await this.pipelinesClientService
|
||||
.findOne({ id }, metadata)
|
||||
.then((res) => {
|
||||
//{pipeline:{tables: {tables: [], input_id: ''}}}
|
||||
let tables = JSON.parse(res.pipeline.config.tables);
|
||||
if (tables?.tables) tables = tables.tables;
|
||||
Object.assign(res.pipeline, {
|
||||
transformations: res.pipeline.transformations
|
||||
? JSON.parse(res.pipeline.transformations)
|
||||
: [],
|
||||
config: {
|
||||
cron: res.pipeline.config.cron,
|
||||
tables,
|
||||
},
|
||||
properties: res.pipeline.properties
|
||||
? JSON.parse(res.pipeline.properties)
|
||||
: {},
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
async update(
|
||||
@Language() language: LanguageEnum,
|
||||
@Body() updatePipelineDto,
|
||||
@@ -309,47 +291,12 @@ export class PipelinesController {
|
||||
return response;
|
||||
}
|
||||
|
||||
@Patch('/:pipelineId/inputs/:id')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
@UseGuards(PipelineExecutionGuard)
|
||||
async updatePipelineInput(
|
||||
@Language() language: LanguageEnum,
|
||||
@Body() pipelineInputDTO: UpdatePlatformInputRequest,
|
||||
@Param('id') inputId: string,
|
||||
@Param('pipelineId') pipelineId: string,
|
||||
@User() user: RequestUser,
|
||||
) {
|
||||
this.logger.info('PipelinesController - update', { user });
|
||||
|
||||
const info: Info = {
|
||||
user_id: user.user_id,
|
||||
customer: user.customer_name,
|
||||
customer_id: user.customer_id,
|
||||
pipeline_id: pipelineId
|
||||
};
|
||||
|
||||
const metadata = PackTheMetadata(user);
|
||||
|
||||
const response = await this.pipelinesClientService.updatePipelineInput(
|
||||
pipelineId,
|
||||
inputId,
|
||||
pipelineInputDTO,
|
||||
info,
|
||||
user,
|
||||
metadata,
|
||||
);
|
||||
|
||||
this.logger.info('PipelinesController - update: OK', { user });
|
||||
return response;
|
||||
}
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Put('/:id')
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
description: 'This method is deprecated. Please use PATCH instead',
|
||||
})
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW, PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
async updateDeprecated(
|
||||
@Language() language: LanguageEnum,
|
||||
@Body() updatePipelineDto,
|
||||
@@ -362,25 +309,9 @@ export class PipelinesController {
|
||||
return response;
|
||||
}
|
||||
|
||||
@Patch('/:id/upgrade')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async upgradeConnector(
|
||||
@Language() language: LanguageEnum,
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser
|
||||
) {
|
||||
this.logger.info('PipelinesController - upgrade connector');
|
||||
|
||||
const metadata = PackTheMetadata(user);
|
||||
|
||||
await this.pipelinesClientService.upgrade(id, metadata);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiNoContentResponse()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.DELETE)
|
||||
async delete(@Param('id') id: string, @User() user: RequestUser) {
|
||||
this.logger.info('PipelinesController - delete', { user });
|
||||
const metadata = PackTheMetadata({
|
||||
@@ -394,7 +325,7 @@ export class PipelinesController {
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Post('/init-upload')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW)
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.PIPELINE.permissions.CREATE)
|
||||
async initUploadFile(
|
||||
@User() user: RequestUser,
|
||||
@Body() body: IInitUploadCSVFile,
|
||||
@@ -426,7 +357,7 @@ export class PipelinesController {
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Post('/complete-upload')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW)
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.PIPELINE.permissions.CREATE)
|
||||
async completeUploadFile(
|
||||
@User() user: RequestUser,
|
||||
@Body() body: ICompleteUploadCSVFile,
|
||||
@@ -444,7 +375,7 @@ export class PipelinesController {
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Post('/file')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.IMPORT_FILES.permissions.VIEW)
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.PIPELINE.permissions.CREATE)
|
||||
async uploadedFile(
|
||||
@User() user: RequestUser,
|
||||
@Body() body: ICreatePipelineCSVFile,
|
||||
@@ -494,7 +425,6 @@ export class PipelinesController {
|
||||
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@Post('start/:id')
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.CREATE)
|
||||
async activate(@Param('id') id: string, @Body() body) {
|
||||
const { info } = body;
|
||||
|
||||
@@ -506,82 +436,8 @@ export class PipelinesController {
|
||||
},
|
||||
);
|
||||
|
||||
const response = await this.pipelinesClientService.runPipeline({ id, info });
|
||||
const response = await this.oldPipelinesService.runPipeline({ id, info });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ---- CDC pipeline operations (Kafka Connect backed) ----
|
||||
// These operate on an existing pipeline, so they require UPDATE (not CREATE).
|
||||
|
||||
@Get(':id/live-status')
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.GET)
|
||||
async getLiveStatus(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
): Promise<Messages.PipelineV2GetLiveStatusResponse> {
|
||||
this.logger.info('PipelinesController - getLiveStatus', { id });
|
||||
const metadata = PackTheMetadata(user);
|
||||
return this.pipelinesClientService.getLiveStatus(id, metadata);
|
||||
}
|
||||
|
||||
@Post(':id/pause')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
async pause(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesController - pause', { id });
|
||||
const metadata = PackTheMetadata(user);
|
||||
return this.pipelinesClientService.pause(id, metadata);
|
||||
}
|
||||
|
||||
@Post(':id/unpause')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
async unpause(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesController - unpause', { id });
|
||||
const metadata = PackTheMetadata(user);
|
||||
return this.pipelinesClientService.unpause(id, metadata);
|
||||
}
|
||||
|
||||
@Post(':id/restart')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
async restart(
|
||||
@Param('id') id: string,
|
||||
@User() user: RequestUser,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesController - restart', { id });
|
||||
const metadata = PackTheMetadata(user);
|
||||
return this.pipelinesClientService.restart(id, metadata);
|
||||
}
|
||||
|
||||
@Post(':id/jobs/:jobId/reset-state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiInternalOnlyEndpoint()
|
||||
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
|
||||
async resetJobState(
|
||||
@Param('id') id: string,
|
||||
@Param('jobId') jobId: string,
|
||||
@Body() body: { schedule_minutes?: number },
|
||||
@User() user: RequestUser,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesController - resetJobState', { id, jobId });
|
||||
const metadata = PackTheMetadata(user);
|
||||
return this.pipelinesClientService.resetJobState(
|
||||
id,
|
||||
jobId,
|
||||
body?.schedule_minutes,
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
@@ -7,28 +7,23 @@ import { PipelinesService } from './pipelines.service';
|
||||
|
||||
import { PipelinesClientConfiguration } from './pipelines-client';
|
||||
|
||||
import { PipelinesModule as OldPipelineModule } from 'src/modules/pipelines/pipelines.module';
|
||||
import { ConnectorModule } from '../connector/connector.module';
|
||||
import { InputsModule } from '../inputs/inputs.module';
|
||||
import { TransformationsModule } from '../transformations/transformations.module';
|
||||
import { PlatformApiModule } from '../platform-api/platform-api.module';
|
||||
import { NimbusServicesModule } from 'src/services/nimbus/nimbus.module';
|
||||
import { NimbusService } from 'src/services/nimbus/nimbus.service';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
|
||||
const client = new PipelinesClientConfiguration();
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClientsModule.register([client.providerOptions]),
|
||||
OldPipelineModule,
|
||||
ConnectorModule,
|
||||
InputsModule,
|
||||
TransformationsModule,
|
||||
forwardRef(() => PlatformApiModule),
|
||||
NimbusServicesModule,
|
||||
CatalogModule
|
||||
],
|
||||
controllers: [PipelinesController],
|
||||
providers: [PipelinesService, DadosferaLogger, NimbusService],
|
||||
providers: [PipelinesService, DadosferaLogger],
|
||||
exports: [PipelinesService],
|
||||
})
|
||||
export class PipelinesV2Module {}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// These imported modules pull in gRPC client-config / service modules that read
|
||||
// process.env at load time; mock them (hoisted before imports) so the spec needs
|
||||
// no runtime env. Each mock severs an entire import subtree while still providing
|
||||
// a class usable as a value/DI token. Mirrors platform-api.controller.spec.ts.
|
||||
jest.mock('./pipelines-client', () => ({ PipelinesClientConfiguration: class {} }));
|
||||
jest.mock('../connector/client.service', () => ({ ConnectorClientService: class {} }));
|
||||
jest.mock('../inputs/inputs.service', () => ({ InputsService: class {} }));
|
||||
jest.mock('../transformations/transformations.service', () => ({ TransformationsService: class {} }));
|
||||
jest.mock('../platform-api/platform-api.service', () => ({ PlatformApiService: class {} }));
|
||||
jest.mock('src/services/nimbus/nimbus.service', () => ({ NimbusService: class {} }));
|
||||
jest.mock('../catalog/catalog.service', () => ({ CatalogService: class {} }));
|
||||
|
||||
import { of } from 'rxjs';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
const cdcOldInput = { input: { plugin: 'mysql_cdc', tables: [] } };
|
||||
const batchOldInput = { input: { plugin: 'mysql', type: 'database', tables: [] } };
|
||||
|
||||
const updateResponse = {
|
||||
input: { type: 'database' },
|
||||
tablesUpdate: [],
|
||||
dataAssetUpdate: [],
|
||||
};
|
||||
|
||||
const user: any = { customer_modules: [] };
|
||||
const updateInputDTO: any = { tables: [] };
|
||||
const info: any = { customer: 'cust' };
|
||||
const metadata: any = {};
|
||||
|
||||
function buildService(oldInput: any) {
|
||||
const inputsService: any = {
|
||||
findOne: jest.fn().mockResolvedValue(oldInput),
|
||||
update: jest.fn().mockResolvedValue(updateResponse),
|
||||
rollbackUpdate: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const nimbusService: any = { renameTable: jest.fn().mockResolvedValue({}) };
|
||||
|
||||
const service = new PipelinesService(
|
||||
{ logger } as any, // dadosferaLogger
|
||||
{} as any, // grpcClient
|
||||
{} as any, // connectorService
|
||||
inputsService, // inputsService
|
||||
{} as any, // transformationsService
|
||||
{} as any, // platformAPI
|
||||
nimbusService, // nimbusService
|
||||
{} as any, // catalogService
|
||||
);
|
||||
|
||||
const updatePlatformJobsSpy = jest
|
||||
.spyOn(service, 'updatePlatformJobs')
|
||||
.mockResolvedValue(undefined as any);
|
||||
|
||||
return { service, inputsService, updatePlatformJobsSpy };
|
||||
}
|
||||
|
||||
describe('PipelinesService - updatePipelineInput', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('CDC input skips updatePlatformJobs', async () => {
|
||||
const { service, inputsService, updatePlatformJobsSpy } = buildService(cdcOldInput);
|
||||
|
||||
const result = await service.updatePipelineInput(
|
||||
'pipeline-id',
|
||||
'input-id',
|
||||
updateInputDTO,
|
||||
info,
|
||||
user,
|
||||
metadata,
|
||||
);
|
||||
|
||||
expect(updatePlatformJobsSpy).not.toHaveBeenCalled();
|
||||
expect(inputsService.update).toHaveBeenCalled();
|
||||
expect(result).toBe(updateResponse);
|
||||
});
|
||||
|
||||
it('batch input calls updatePlatformJobs', async () => {
|
||||
const { service, inputsService, updatePlatformJobsSpy } = buildService(batchOldInput);
|
||||
|
||||
await service.updatePipelineInput(
|
||||
'pipeline-id',
|
||||
'input-id',
|
||||
updateInputDTO,
|
||||
info,
|
||||
user,
|
||||
metadata,
|
||||
);
|
||||
|
||||
expect(updatePlatformJobsSpy).toHaveBeenCalled();
|
||||
expect(inputsService.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PipelinesService - create', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
// The body's `config` (carrying CDC destinations) must reach pi-factory as a
|
||||
// JSON string — the gRPC proto field is a string, so an object would be
|
||||
// stripped on the wire. Mirrors how `properties` is serialized.
|
||||
it('serializes the body config into the gRPC create request', async () => {
|
||||
const { service } = buildService(cdcOldInput);
|
||||
|
||||
let captured: any;
|
||||
(service as any).pipelineWriteService = {
|
||||
PipelineV2Create: (req: any) => {
|
||||
captured = req;
|
||||
// The service does `lastValueFrom(...)`; return a real Observable.
|
||||
return of({ pipeline: {} });
|
||||
},
|
||||
};
|
||||
|
||||
const body: any = {
|
||||
name: 'p',
|
||||
input_id: 'i',
|
||||
transformations_ids: [],
|
||||
tags: [],
|
||||
properties: { schema: 'cadastros' },
|
||||
config: {
|
||||
cron: '@once',
|
||||
tables: [
|
||||
{
|
||||
name: 'pedidos',
|
||||
destinations: {
|
||||
raw: { table_schema: 'PUBLIC', table_name: 'pedidos_001' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await service.create(body, metadata);
|
||||
|
||||
expect(typeof captured.config).toBe('string');
|
||||
expect(JSON.parse(captured.config).tables[0].destinations.raw.table_name).toBe(
|
||||
'pedidos_001',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
import { isCdcPlugin } from 'src/utils/cdc';
|
||||
/* eslint-disable no-async-promise-executor */
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
@@ -19,8 +16,8 @@ import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { PipelinesClientConfiguration } from './pipelines-client';
|
||||
import { ICreatePipelineV2Req, IIdRequest, UpdatePlatformInputRequest, UpdateTableDTO } from './interfaces';
|
||||
import { PipelineV2CreateRequest, AddCdcJobsRequest, AddCdcJobsResponse } from '@dadosfera/protospack-v2/dist/lib/PipelineV2/interfaces/messages';
|
||||
import { ICreatePipelineV2Req } from './interfaces';
|
||||
import { PipelineV2CreateRequest } from '@dadosfera/protospack-v2/dist/lib/PipelineV2/interfaces/messages';
|
||||
import { Metadata } from '@grpc/grpc-js';
|
||||
import { ConnectorClientService } from '../connector/client.service';
|
||||
import { InputsService } from '../inputs/inputs.service';
|
||||
@@ -29,17 +26,6 @@ import { TransformationsService } from '../transformations/transformations.servi
|
||||
import { getObjValueFromPath, objHasPath } from 'src/utils/ObjValueFromPath';
|
||||
import ErrorCodes from 'src/utils/errorCodes';
|
||||
import ErrorBuilder from 'src/utils/ErrorBuilder';
|
||||
import { PlatformApiService } from '../platform-api/platform-api.service';
|
||||
import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
|
||||
import { TableUpdate } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/messages';
|
||||
import { AxiosError } from 'axios';
|
||||
import { NimbusService } from 'src/services/nimbus/nimbus.service';
|
||||
import { PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum';
|
||||
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
|
||||
import { IDataAsset } from '../catalog/dtos';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
|
||||
type RollbackPromise = () => Promise<any>;
|
||||
|
||||
export class PipelinesService implements OnModuleInit {
|
||||
logger: DadosferaLogger;
|
||||
@@ -53,9 +39,6 @@ export class PipelinesService implements OnModuleInit {
|
||||
private readonly connectorService: ConnectorClientService,
|
||||
private readonly inputsService: InputsService,
|
||||
private readonly transformationsService: TransformationsService,
|
||||
private readonly platformAPI: PlatformApiService,
|
||||
private readonly nimbusService: NimbusService,
|
||||
private readonly catalogService: CatalogService
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
@@ -106,10 +89,6 @@ export class PipelinesService implements OnModuleInit {
|
||||
transformations_ids: body.transformations_ids,
|
||||
tags: body.tags,
|
||||
properties: body.properties && JSON.stringify(body.properties),
|
||||
// JSON-serialize the create body config so it survives the gRPC wire
|
||||
// (the proto field is a string). pi-factory's CDC path reads
|
||||
// config.tables[].destinations to honor a user-supplied raw table name.
|
||||
config: body.config && JSON.stringify(body.config),
|
||||
};
|
||||
|
||||
const createPipelineResponse = await lastValueFrom(
|
||||
@@ -159,68 +138,11 @@ export class PipelinesService implements OnModuleInit {
|
||||
const findOnePipelineResponse = await lastValueFrom(
|
||||
this.pipelineReadService.PipelineV2FindOne(data, metadata),
|
||||
);
|
||||
console.log('pipeline find one response', findOnePipelineResponse);
|
||||
this.logger.info('Done');
|
||||
|
||||
return findOnePipelineResponse;
|
||||
}
|
||||
|
||||
// CDC lifecycle operations (Kafka Connect backed).
|
||||
async pause(
|
||||
id: string,
|
||||
metadata,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesClientService - Pause');
|
||||
return lastValueFrom(
|
||||
this.pipelineWriteService.PipelineV2Pause({ id }, metadata),
|
||||
);
|
||||
}
|
||||
|
||||
async unpause(
|
||||
id: string,
|
||||
metadata,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesClientService - Unpause');
|
||||
return lastValueFrom(
|
||||
this.pipelineWriteService.PipelineV2Unpause({ id }, metadata),
|
||||
);
|
||||
}
|
||||
|
||||
async restart(
|
||||
id: string,
|
||||
metadata,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesClientService - Restart');
|
||||
return lastValueFrom(
|
||||
this.pipelineWriteService.PipelineV2Restart({ id }, metadata),
|
||||
);
|
||||
}
|
||||
|
||||
async resetJobState(
|
||||
id: string,
|
||||
job_id: string,
|
||||
schedule_minutes: number | undefined,
|
||||
metadata,
|
||||
): Promise<Messages.PipelineV2OperationResponse> {
|
||||
this.logger.info('PipelinesClientService - ResetJobState');
|
||||
return lastValueFrom(
|
||||
this.pipelineWriteService.PipelineV2ResetJobState(
|
||||
{ id, job_id, schedule_minutes },
|
||||
metadata,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getLiveStatus(
|
||||
id: string,
|
||||
metadata,
|
||||
): Promise<Messages.PipelineV2GetLiveStatusResponse> {
|
||||
this.logger.info('PipelinesClientService - GetLiveStatus');
|
||||
return lastValueFrom(
|
||||
this.pipelineReadService.PipelineV2GetLiveStatus({ id }, metadata),
|
||||
);
|
||||
}
|
||||
|
||||
async update(
|
||||
UpdatePipelineRequest: Messages.PipelineV2UpdateRequest,
|
||||
metadata,
|
||||
@@ -238,15 +160,6 @@ export class PipelinesService implements OnModuleInit {
|
||||
return updatePipelineResponse;
|
||||
}
|
||||
|
||||
async upgrade(id: string, metadata: Metadata) {
|
||||
await lastValueFrom(
|
||||
this.pipelineWriteService.Upgrade(
|
||||
{ id },
|
||||
metadata,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async remove(data: { id: string; metadata: Metadata; user: RequestUser }) {
|
||||
const { id, metadata, user } = data;
|
||||
const info = {
|
||||
@@ -426,329 +339,4 @@ export class PipelinesService implements OnModuleInit {
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async updatePipelineInput(pipelineId: string, inputId: string, updateInputDTO: UpdatePlatformInputRequest, info: Info, user: RequestUser, metadata: Metadata) {
|
||||
this.logger.info('InputClientService - Update');
|
||||
|
||||
const {
|
||||
input: oldInput
|
||||
} = await this.inputsService.findOne({
|
||||
id: inputId,
|
||||
info: info
|
||||
});
|
||||
|
||||
this.logger.info('Update Dynamo Reference :' + JSON.stringify(oldInput));
|
||||
const isCdc = isCdcPlugin(oldInput.plugin);
|
||||
const pipelineIdFormat = pipelineId.split('-').join('_');
|
||||
const rollback: RollbackPromise[] = [];
|
||||
|
||||
const updateInputResponse = await this.inputsService.update(
|
||||
inputId,
|
||||
updateInputDTO,
|
||||
info,
|
||||
metadata
|
||||
);
|
||||
|
||||
const inputRollback = () => {
|
||||
this.logger.info("exec rollback to input: " + JSON.stringify(oldInput));
|
||||
return this.inputsService.rollbackUpdate(
|
||||
{
|
||||
id: inputId,
|
||||
dataAssetUpdate: updateInputResponse.dataAssetUpdate,
|
||||
tables: oldInput.tables,
|
||||
info
|
||||
}
|
||||
) as Promise<any>;
|
||||
}
|
||||
|
||||
rollback.push(inputRollback);
|
||||
|
||||
this.logger.info("Input Update Response: " + JSON.stringify(updateInputResponse))
|
||||
|
||||
const nimbusUpdates = updateInputResponse?.tablesUpdate || [];
|
||||
|
||||
if (user.customer_modules.includes('catalog')) {
|
||||
nimbusUpdates.forEach(update => {
|
||||
const nimbusRollback = () => {
|
||||
return this.nimbusService.renameTable(
|
||||
info.customer,
|
||||
update.database,
|
||||
{
|
||||
table_name: update.table_name,
|
||||
table_schema: update.table_schema
|
||||
},
|
||||
{
|
||||
table_name: update.old_table_name,
|
||||
table_schema: update.old_table_schema
|
||||
}
|
||||
);
|
||||
}
|
||||
rollback.push(nimbusRollback);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.updateNimbus(info.customer, nimbusUpdates);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
if (error instanceof AxiosError) {
|
||||
this.logger.error(JSON.stringify(error.response.data));
|
||||
}
|
||||
await this.executeRenameRollback(rollback);
|
||||
|
||||
throw new Error("Error Nimbus updating tables");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCdc) {
|
||||
try {
|
||||
await this.updatePlatformJobs(
|
||||
pipelineIdFormat,
|
||||
updateInputResponse.input.type,
|
||||
updateInputDTO,
|
||||
user
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
await this.executeRenameRollback(rollback)
|
||||
throw new Error("Error Platform API updating jobs");
|
||||
}
|
||||
} else {
|
||||
this.logger.info('CDC input: skipping updatePlatformJobs (batch sync_mode/memory do not apply to CDC jobs)');
|
||||
}
|
||||
|
||||
return updateInputResponse;
|
||||
}
|
||||
|
||||
private async executeRenameRollback(request: RollbackPromise[]) {
|
||||
this.logger.info('rollback steps: ' + request.length)
|
||||
const result = await Promise.allSettled(request.map(func => func()));
|
||||
result.forEach(promise => {
|
||||
this.logger.info("Promise finish with status: " + promise.status)
|
||||
|
||||
if (promise.status === "rejected") {
|
||||
this.logger.error("reject with: " + JSON.stringify(promise.reason || {}))
|
||||
}
|
||||
|
||||
if (promise.status === "fulfilled") {
|
||||
this.logger.info("success with: " + JSON.stringify(promise.value || {}))
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private async updateNimbus(customer: string, changes: TableUpdate[]) {
|
||||
// throw new Error("teste error nimbus");
|
||||
this.logger.info('Nimbus Changes: ' + JSON.stringify(changes));
|
||||
if(!changes || changes.length === 0) return;
|
||||
|
||||
const requests = changes.map(change => {
|
||||
return this.nimbusService.renameTable(customer, change.database, {
|
||||
table_name: change.old_table_name,
|
||||
table_schema: change.old_table_schema
|
||||
}, {
|
||||
table_name: change.table_name,
|
||||
table_schema: change.table_schema
|
||||
});
|
||||
})
|
||||
|
||||
const values = await Promise.allSettled(requests);
|
||||
|
||||
const success = values.map(request => request.status === "fulfilled")
|
||||
|
||||
this.logger.info("Updates with succes: " + success.length);
|
||||
|
||||
values.forEach(promise => {
|
||||
this.logger.info("Promise finish with status: " + promise.status)
|
||||
|
||||
if (promise.status === "rejected") {
|
||||
this.logger.error("reject with: " + JSON.stringify(promise.reason || {}));
|
||||
throw new Error(promise.reason );
|
||||
}
|
||||
|
||||
if (promise.status === "fulfilled") {
|
||||
this.logger.info("success with: " + JSON.stringify(promise.value || {}));
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async updatePlatformJobs(pipelineId: string, pipelineType: string, updateInputDTO: UpdatePlatformInputRequest, user: RequestUser) {
|
||||
const jobsUpdated = [];
|
||||
|
||||
for (const [index, table] of updateInputDTO.tables.entries()) {
|
||||
const jobUpdate = {
|
||||
job_id: `${pipelineId}_${index}`,
|
||||
}
|
||||
|
||||
if (table.type !== "incremental_with_qualify") {
|
||||
delete table.destinations?.qualify;
|
||||
}
|
||||
|
||||
if (table.memory) {
|
||||
jobUpdate["memory"] = {
|
||||
amount: table.memory * 1000
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('Updating input reference for table: ' + table.name);
|
||||
let hasUpdateSyncMode = false;
|
||||
|
||||
const jobSyncMode = {}
|
||||
|
||||
if (table.columns) {
|
||||
hasUpdateSyncMode = true;
|
||||
jobSyncMode['column_include_list'] = table.columns;
|
||||
}
|
||||
|
||||
if (table.reference_column) {
|
||||
hasUpdateSyncMode = true;
|
||||
jobSyncMode['incremental_column_name'] = table.reference_column.name;
|
||||
jobSyncMode['incremental_column_type'] = table.reference_column.type;
|
||||
}
|
||||
|
||||
if (table.identifier_columns) {
|
||||
hasUpdateSyncMode = true;
|
||||
jobSyncMode['primary_keys'] = table.identifier_columns;
|
||||
}
|
||||
|
||||
if (table.type) {
|
||||
hasUpdateSyncMode = true;
|
||||
|
||||
jobSyncMode['target_load_type'] = table.type;
|
||||
}
|
||||
|
||||
if(hasUpdateSyncMode) {
|
||||
jobUpdate["sync_mode"] = jobSyncMode;
|
||||
}
|
||||
|
||||
if (Object.keys(table.destinations).length > 1) {
|
||||
let hasChanges = false
|
||||
const jobRenameTables = {
|
||||
raw: {},
|
||||
qualify: {}
|
||||
}
|
||||
|
||||
if (Object.keys(table.destinations.raw).length > 1) {
|
||||
hasChanges = true;
|
||||
jobRenameTables.raw = table.destinations.raw;
|
||||
}
|
||||
|
||||
if (Object.keys(table.destinations.qualify).length > 1) {
|
||||
hasChanges = true;
|
||||
jobRenameTables.qualify = table.destinations.qualify;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
jobUpdate['rename_tables'] = jobRenameTables;
|
||||
}
|
||||
}
|
||||
|
||||
jobsUpdated.push(jobUpdate);
|
||||
}
|
||||
|
||||
this.logger.info('Request body:' + JSON.stringify({
|
||||
jobs_updated: jobsUpdated
|
||||
}));
|
||||
|
||||
const response = await this.platformAPI.proxy(
|
||||
'PUT',
|
||||
`/pipeline/${pipelineId}/jobs`,
|
||||
user,
|
||||
{
|
||||
job_updates: jobsUpdated
|
||||
}
|
||||
)
|
||||
this.logger.info('Platform api response: ' + JSON.stringify(response));
|
||||
}
|
||||
|
||||
async findAllDataAssetByPipeline(data: {
|
||||
pipeline: string,
|
||||
object?: string
|
||||
}, user: RequestUser) {
|
||||
const metadata = PackTheMetadata(user);
|
||||
|
||||
const isDataAdmin = user.permissions.includes(
|
||||
PERMISSIONS_GROUPS.CATALOG.permissions.DATA_MANAGER.seqid,
|
||||
);
|
||||
|
||||
let has_permission = false;
|
||||
|
||||
const {
|
||||
data_assets: resultString
|
||||
} = await lastValueFrom(
|
||||
this.pipelineReadService.FindAllDataAssetByPipeline(data, metadata)
|
||||
);
|
||||
|
||||
const result = JSON.parse(resultString) as any;
|
||||
const data_assets: IDataAsset[] = []
|
||||
result.forEach(data_asset => {
|
||||
if (data_asset?.owner === user.username) has_permission = true;
|
||||
|
||||
for (const role of user.roles) {
|
||||
if (data_asset.roles.includes(role)) has_permission = true;
|
||||
}
|
||||
|
||||
if (data_asset.users.includes(user.user_id)) has_permission = true;
|
||||
|
||||
if (isDataAdmin || has_permission) {
|
||||
delete data_asset.p_roles;
|
||||
delete data_asset.p_users;
|
||||
data_assets.push(data_asset as IDataAsset);
|
||||
}
|
||||
});
|
||||
|
||||
const assets = await this.catalogService.getAssetsUsersAndRoles(data_assets, user.customer_id);
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
async getPipelineStatus(data) {
|
||||
this.logger.info('PipelinesClientService - GetPipelineStatus');
|
||||
|
||||
const statusPipelineResponse = await lastValueFrom(
|
||||
this.pipelineReadService.PipelineV2GetPipelineV2Status(data),
|
||||
)
|
||||
.then((res) => {
|
||||
const statusArray =
|
||||
res.status?.sort((a, b) => {
|
||||
if (a.id < b.id) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}) || [];
|
||||
return { status: statusArray };
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error(err.message);
|
||||
throw new Error(err);
|
||||
});
|
||||
this.logger.info('Done');
|
||||
|
||||
return statusPipelineResponse;
|
||||
}
|
||||
|
||||
async addCdcJobs(data: AddCdcJobsRequest): Promise<AddCdcJobsResponse> {
|
||||
return lastValueFrom(this.pipelineWriteService.AddCdcJobs(data));
|
||||
}
|
||||
|
||||
async runPipeline({ id, info }: IIdRequest) {
|
||||
this.logger.info('PipelinesClientService - RunPipeline');
|
||||
const statusPipelineResponse = await lastValueFrom(
|
||||
this.pipelineWriteService.PipelineV2TriggerPipelineV2({ id, info }),
|
||||
).catch((err) => {
|
||||
this.logger.error(err.message);
|
||||
throw new Error(err);
|
||||
});
|
||||
|
||||
if (statusPipelineResponse.status == false) {
|
||||
throw new ConflictException(
|
||||
'This pipeline is not ready yet to execute, Try again later!',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info('Done');
|
||||
return statusPipelineResponse;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
// These service modules pull in gRPC client-config modules that read
|
||||
// process.env at load time; mock them (hoisted before imports) so the spec
|
||||
// needs no runtime env.
|
||||
jest.mock('../inputs/inputs.service', () => ({ InputsService: class {} }));
|
||||
jest.mock('../pipelinesV2/pipelines.service', () => ({ PipelinesService: class {} }));
|
||||
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PipelineTablesService } from './pipeline-tables.service';
|
||||
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
const user = { customer_id: 'c1', customer_name: 'cust', user_id: 'u1' } as never;
|
||||
const info = { customer_id: 'c1', customer: 'cust', user_id: 'u1' };
|
||||
|
||||
type Mocks = {
|
||||
proxy: jest.Mock;
|
||||
markTableDeleted: jest.Mock;
|
||||
unmarkTableDeleted: jest.Mock;
|
||||
addCdcTable: jest.Mock;
|
||||
removeCdcTable: jest.Mock;
|
||||
addCdcJobs: jest.Mock;
|
||||
};
|
||||
|
||||
function build(): { service: PipelineTablesService; m: Mocks } {
|
||||
const m: Mocks = {
|
||||
proxy: jest.fn(),
|
||||
markTableDeleted: jest.fn().mockResolvedValue({ is_deleted: true, deleted_at: 't' }),
|
||||
unmarkTableDeleted: jest.fn().mockResolvedValue({}),
|
||||
addCdcTable: jest.fn().mockResolvedValue({ input: {} }),
|
||||
removeCdcTable: jest.fn().mockResolvedValue({ input: {} }),
|
||||
addCdcJobs: jest.fn(),
|
||||
};
|
||||
type Deps = ConstructorParameters<typeof PipelineTablesService>;
|
||||
const service = new PipelineTablesService(
|
||||
{ proxy: m.proxy } as unknown as Deps[0],
|
||||
{
|
||||
markTableDeleted: m.markTableDeleted,
|
||||
unmarkTableDeleted: m.unmarkTableDeleted,
|
||||
addCdcTable: m.addCdcTable,
|
||||
removeCdcTable: m.removeCdcTable,
|
||||
} as unknown as Deps[1],
|
||||
{ addCdcJobs: m.addCdcJobs } as unknown as Deps[2],
|
||||
{ logger } as unknown as Deps[3],
|
||||
);
|
||||
return { service, m };
|
||||
}
|
||||
|
||||
const pipelineWithJobs = (connector: string) => ({
|
||||
jobs: [
|
||||
{ job_id: 'p_0', input: { connector, table_name: 'pedidos' } },
|
||||
{ job_id: 'p_1', input: { connector, table_name: 'clientes' } },
|
||||
{ job_id: 'p_2', input: { connector, table_name: 'produtos' } },
|
||||
],
|
||||
});
|
||||
|
||||
const deleteCalls = (proxy: jest.Mock) =>
|
||||
proxy.mock.calls.filter(([method]) => method === 'DELETE');
|
||||
|
||||
describe('PipelineTablesService.removeTable', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it.each(['cdc', 'jdbc'])(
|
||||
'removes a %s table through the single pipeline jobs route',
|
||||
async (connector) => {
|
||||
const { service, m } = build();
|
||||
m.proxy.mockImplementation((method: string) =>
|
||||
Promise.resolve(method === 'GET' ? pipelineWithJobs(connector) : {}),
|
||||
);
|
||||
|
||||
const result = await service.removeTable('pi-d', 'iid', 'pedidos', user);
|
||||
|
||||
expect(m.markTableDeleted).toHaveBeenCalledWith({ input_id: 'iid', table_name: 'pedidos', info });
|
||||
expect(m.proxy).toHaveBeenCalledWith('GET', '/pipeline/pi_d', user);
|
||||
expect(deleteCalls(m.proxy)).toEqual([
|
||||
['DELETE', '/pipeline/pi_d/jobs', user, { job_ids: ['p_0'], delete_snowflake_tables: false }],
|
||||
]);
|
||||
expect(result).toEqual({ name: 'pedidos', is_deleted: true, deleted_at: 't' });
|
||||
expect(m.unmarkTableDeleted).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rolls back the mark when the platform delete fails', async () => {
|
||||
const { service, m } = build();
|
||||
m.proxy.mockImplementation((method: string) =>
|
||||
method === 'GET' ? Promise.resolve(pipelineWithJobs('cdc')) : Promise.reject(new Error('platform boom')),
|
||||
);
|
||||
|
||||
await expect(service.removeTable('pid', 'iid', 'pedidos', user)).rejects.toThrow('platform boom');
|
||||
expect(m.unmarkTableDeleted).toHaveBeenCalledWith({ input_id: 'iid', table_name: 'pedidos', info });
|
||||
});
|
||||
|
||||
it('404s (and rolls back) when the table has no job', async () => {
|
||||
const { service, m } = build();
|
||||
m.proxy.mockResolvedValue(pipelineWithJobs('cdc'));
|
||||
|
||||
await expect(service.removeTable('pid', 'iid', 'ghost', user)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(deleteCalls(m.proxy)).toHaveLength(0);
|
||||
expect(m.unmarkTableDeleted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PipelineTablesService.removeTables', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('removes N tables in ONE platform call (connectors reconfigured once)', async () => {
|
||||
const { service, m } = build();
|
||||
m.proxy.mockImplementation((method: string) =>
|
||||
Promise.resolve(method === 'GET' ? pipelineWithJobs('cdc') : {}),
|
||||
);
|
||||
|
||||
const result = await service.removeTables('pid', 'iid', ['pedidos', 'produtos'], user);
|
||||
|
||||
expect(m.markTableDeleted).toHaveBeenCalledTimes(2);
|
||||
expect(deleteCalls(m.proxy)).toEqual([
|
||||
['DELETE', '/pipeline/pid/jobs', user, { job_ids: ['p_0', 'p_2'], delete_snowflake_tables: false }],
|
||||
]);
|
||||
expect(result).toEqual({ table_names: ['pedidos', 'produtos'], deleted: true });
|
||||
});
|
||||
|
||||
it("rolls back only this call's marks when the platform delete fails", async () => {
|
||||
const { service, m } = build();
|
||||
m.proxy.mockImplementation((method: string) =>
|
||||
method === 'GET' ? Promise.resolve(pipelineWithJobs('cdc')) : Promise.reject(new Error('platform boom')),
|
||||
);
|
||||
|
||||
await expect(service.removeTables('pid', 'iid', ['pedidos', 'clientes'], user)).rejects.toThrow();
|
||||
|
||||
const unmarked = m.unmarkTableDeleted.mock.calls.map(([arg]) => arg.table_name).sort();
|
||||
expect(unmarked).toEqual(['clientes', 'pedidos']);
|
||||
});
|
||||
|
||||
it('rolls back the marks made so far if a later mark fails (atomic)', async () => {
|
||||
const { service, m } = build();
|
||||
m.markTableDeleted
|
||||
.mockResolvedValueOnce({ is_deleted: true, deleted_at: 't' })
|
||||
.mockRejectedValueOnce(new Error('dynamo boom'));
|
||||
|
||||
await expect(service.removeTables('pid', 'iid', ['pedidos', 'clientes'], user)).rejects.toThrow('dynamo boom');
|
||||
|
||||
expect(m.unmarkTableDeleted).toHaveBeenCalledTimes(1);
|
||||
expect(m.unmarkTableDeleted.mock.calls[0][0].table_name).toBe('pedidos');
|
||||
expect(m.proxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s when a requested table has no matching job and rolls back the marks', async () => {
|
||||
const { service, m } = build();
|
||||
m.proxy.mockResolvedValue(pipelineWithJobs('cdc'));
|
||||
|
||||
await expect(service.removeTables('pid', 'iid', ['pedidos', 'ghost'], user)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(m.unmarkTableDeleted).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps going when a rollback step itself fails', async () => {
|
||||
const { service, m } = build();
|
||||
m.proxy.mockImplementation((method: string) =>
|
||||
method === 'GET' ? Promise.resolve(pipelineWithJobs('cdc')) : Promise.reject(new Error('platform boom')),
|
||||
);
|
||||
m.unmarkTableDeleted.mockRejectedValueOnce(new Error('unmark boom'));
|
||||
|
||||
await expect(service.removeTables('pid', 'iid', ['pedidos', 'clientes'], user)).rejects.toThrow('platform boom');
|
||||
expect(m.unmarkTableDeleted).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PipelineTablesService.addTable', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
const body = {
|
||||
table_name: 'orders',
|
||||
table_schema: 'public',
|
||||
primary_keys: ['id'],
|
||||
destinations: {
|
||||
raw: { table_schema: 'raw', table_name: 'orders' },
|
||||
qualify: { table_schema: 'qualify', table_name: 'orders' },
|
||||
},
|
||||
};
|
||||
|
||||
const storedTable = (extra: Record<string, unknown> = {}) => ({
|
||||
table_schema: 'public',
|
||||
table_name: 'orders',
|
||||
name: 'orders',
|
||||
primary_keys: ['id'],
|
||||
iceberg_table_name: undefined,
|
||||
iceberg_qualify_table_name: undefined,
|
||||
columns: [],
|
||||
column_exclude_list: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('appends to DynamoDB, then dispatches the platform jobs', async () => {
|
||||
const { service, m } = build();
|
||||
m.addCdcJobs.mockResolvedValue({ job_ids: ['p_2'], skipped: [] });
|
||||
|
||||
const result = await service.addTable('pid', 'iid', body, user);
|
||||
|
||||
expect(m.addCdcTable).toHaveBeenCalledWith({ client_id: 'c1', id: 'iid', table: storedTable(), info });
|
||||
expect(m.addCdcJobs).toHaveBeenCalledWith({
|
||||
pipeline_id: 'pid',
|
||||
input_id: 'iid',
|
||||
tables: [{ table_schema: 'public', table_name: 'orders', primary_keys: ['id'], destinations: body.destinations }],
|
||||
info,
|
||||
});
|
||||
expect(result).toEqual({ job_ids: ['p_2'], skipped: [] });
|
||||
expect(m.removeCdcTable).not.toHaveBeenCalled();
|
||||
expect(m.addCdcTable.mock.invocationCallOrder[0]).toBeLessThan(m.addCdcJobs.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('carries the iceberg names and columns through to the stored CdcTable', async () => {
|
||||
const { service, m } = build();
|
||||
m.addCdcJobs.mockResolvedValue({ job_ids: ['p_3'], skipped: [] });
|
||||
const columns = [
|
||||
{ name: 'id', type: 'int', is_primary_key: true },
|
||||
{ name: 'descr', type: 'varchar(255)', is_primary_key: false },
|
||||
];
|
||||
|
||||
await service.addTable('pid', 'iid', {
|
||||
...body,
|
||||
iceberg_table_name: 'cdc_raw.public__orders',
|
||||
iceberg_qualify_table_name: 'orders_dedup',
|
||||
columns,
|
||||
column_exclude_list: ['descr'],
|
||||
}, user);
|
||||
|
||||
expect(m.addCdcTable).toHaveBeenCalledWith({
|
||||
client_id: 'c1',
|
||||
id: 'iid',
|
||||
table: storedTable({
|
||||
iceberg_table_name: 'cdc_raw.public__orders',
|
||||
iceberg_qualify_table_name: 'orders_dedup',
|
||||
columns,
|
||||
column_exclude_list: ['descr'],
|
||||
}),
|
||||
info,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back the DynamoDB row when AddJobs fails', async () => {
|
||||
const { service, m } = build();
|
||||
m.addCdcJobs.mockRejectedValue(new Error('grpc boom'));
|
||||
|
||||
await expect(service.addTable('pid', 'iid', body, user)).rejects.toThrow('grpc boom');
|
||||
expect(m.removeCdcTable).toHaveBeenCalledWith({ client_id: 'c1', id: 'iid', table_name: 'orders', info });
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
|
||||
|
||||
import { RequestUser } from '../../decorators/user.decorator';
|
||||
import { InputsService } from '../inputs/inputs.service';
|
||||
import { toCdcTable } from '../inputs/cdc-table.mapper';
|
||||
import { PipelinesService } from '../pipelinesV2/pipelines.service';
|
||||
import { PlatformApiService } from './platform-api.service';
|
||||
import { AddCdcTableBody } from './platform-api.dto';
|
||||
|
||||
interface PlatformJob {
|
||||
job_id: string;
|
||||
input?: { table_name?: string; connector?: string } | null;
|
||||
}
|
||||
|
||||
interface PlatformPipeline {
|
||||
jobs?: PlatformJob[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/remove tables of a pipeline: the DynamoDB input (soft-delete flags or
|
||||
* a new CdcTable row) and the platform-api jobs move together, with the
|
||||
* DynamoDB side rolled back when the platform side fails.
|
||||
*
|
||||
* Removal always goes through DELETE /pipeline/{id}/jobs — the one platform
|
||||
* route that dispatches by pipeline type (Airflow refresh for batch, Kafka
|
||||
* Connect reconfiguration for CDC). Landed destination data is kept
|
||||
* (delete_snowflake_tables: false), matching the soft-delete in DynamoDB.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PipelineTablesService {
|
||||
logger: DadosferaLogger;
|
||||
|
||||
constructor(
|
||||
private readonly platformApiService: PlatformApiService,
|
||||
private readonly inputsService: InputsService,
|
||||
private readonly pipelinesClientService: PipelinesService,
|
||||
@Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
async removeTable(pipelineId: string, inputId: string, tableName: string, user: RequestUser) {
|
||||
const info = this.infoOf(user);
|
||||
|
||||
this.logger.info('removeTable: marking table as deleted', { inputId, tableName });
|
||||
const marked = await this.inputsService.markTableDeleted({ input_id: inputId, table_name: tableName, info });
|
||||
|
||||
try {
|
||||
const jobIds = await this.resolveJobIds(pipelineId, [tableName], user);
|
||||
await this.removeJobs(pipelineId, jobIds, user);
|
||||
return { name: tableName, is_deleted: marked.is_deleted ?? true, deleted_at: marked.deleted_at };
|
||||
} catch (error) {
|
||||
this.logger.error('removeTable: platform-api delete failed, rolling back the mark', { tableName, error: error.message });
|
||||
await this.unmarkAll(inputId, [tableName], info);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes N tables with ONE platform call, so CDC connectors are reconfigured once. */
|
||||
async removeTables(pipelineId: string, inputId: string, tableNames: string[], user: RequestUser) {
|
||||
const info = this.infoOf(user);
|
||||
|
||||
// Soft-delete each table, tracking which succeeded so a later failure
|
||||
// only rolls back the marks made in THIS call.
|
||||
const marked: string[] = [];
|
||||
try {
|
||||
for (const name of tableNames) {
|
||||
await this.inputsService.markTableDeleted({ input_id: inputId, table_name: name, info });
|
||||
marked.push(name);
|
||||
}
|
||||
|
||||
const jobIds = await this.resolveJobIds(pipelineId, tableNames, user);
|
||||
await this.removeJobs(pipelineId, jobIds, user);
|
||||
return { table_names: tableNames, deleted: true };
|
||||
} catch (error) {
|
||||
this.logger.error("removeTables: failed, rolling back this call's marks", { tableNames, error: error.message });
|
||||
await this.unmarkAll(inputId, marked, info);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends a CDC table to the DynamoDB input, then dispatches its platform jobs. */
|
||||
async addTable(pipelineId: string, inputId: string, body: AddCdcTableBody, user: RequestUser) {
|
||||
const info = this.infoOf(user);
|
||||
const cdcTable = toCdcTable(body);
|
||||
|
||||
this.logger.info('addTable: appending CDC table to DynamoDB', { inputId, tableName: body.table_name });
|
||||
await this.inputsService.addCdcTable({ client_id: user.customer_id, id: inputId, table: cdcTable, info });
|
||||
|
||||
try {
|
||||
return await this.pipelinesClientService.addCdcJobs({
|
||||
pipeline_id: pipelineId,
|
||||
input_id: inputId,
|
||||
tables: [{
|
||||
table_schema: body.table_schema,
|
||||
table_name: body.table_name,
|
||||
primary_keys: body.primary_keys,
|
||||
destinations: body.destinations,
|
||||
}],
|
||||
info,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('addTable: platform AddJobs failed, rolling back the DynamoDB row', { tableName: body.table_name, error: error.message });
|
||||
try {
|
||||
await this.inputsService.removeCdcTable({ client_id: user.customer_id, id: inputId, table_name: body.table_name, info });
|
||||
} catch (rollbackError) {
|
||||
this.logger.error('addTable: rollback failed', { error: rollbackError.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private infoOf(user: RequestUser): Info {
|
||||
return { customer_id: user.customer_id, customer: user.customer_name, user_id: user.user_id };
|
||||
}
|
||||
|
||||
/** Platform-API replaces '-' with '_' in pipeline IDs. */
|
||||
private normalizePipelineId(id: string): string {
|
||||
return id.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
private async resolveJobIds(pipelineId: string, tableNames: string[], user: RequestUser): Promise<string[]> {
|
||||
const normalizedPipelineId = this.normalizePipelineId(pipelineId);
|
||||
const pipeline: PlatformPipeline = await this.platformApiService.proxy('GET', `/pipeline/${normalizedPipelineId}`, user);
|
||||
const jobs = pipeline?.jobs ?? [];
|
||||
|
||||
return tableNames.map((name) => {
|
||||
const job = jobs.find((j) => j.input?.table_name === name);
|
||||
if (!job) throw new NotFoundException(`Job for table '${name}' not found in pipeline`);
|
||||
return job.job_id;
|
||||
});
|
||||
}
|
||||
|
||||
private async removeJobs(pipelineId: string, jobIds: string[], user: RequestUser) {
|
||||
const normalizedPipelineId = this.normalizePipelineId(pipelineId);
|
||||
this.logger.info('removeJobs: deleting jobs from platform-api', { jobIds });
|
||||
await this.platformApiService.proxy(
|
||||
'DELETE',
|
||||
`/pipeline/${normalizedPipelineId}/jobs`,
|
||||
user,
|
||||
{ job_ids: jobIds, delete_snowflake_tables: false },
|
||||
);
|
||||
this.logger.info('removeJobs: jobs deleted', { jobIds });
|
||||
}
|
||||
|
||||
private async unmarkAll(inputId: string, tableNames: string[], info: Info) {
|
||||
for (const name of tableNames) {
|
||||
try {
|
||||
await this.inputsService.unmarkTableDeleted({ input_id: inputId, table_name: name, info });
|
||||
} catch (rollbackError) {
|
||||
this.logger.error('rollback failed: table stays marked as deleted', { tableName: name, error: rollbackError.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const PLATFORM_API_CONFIG = {
|
||||
getUrl: (): string => {
|
||||
const url = process.env.PLATFORM_API_URL;
|
||||
if (!url) {
|
||||
throw new Error('PLATFORM_API_URL environment variable is not set');
|
||||
}
|
||||
return url;
|
||||
},
|
||||
region: process.env.AWS_REGION || 'us-east-1',
|
||||
timeout: parseInt(process.env.PLATFORM_API_TIMEOUT || '30000', 10),
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
// These service modules pull in gRPC client-config modules that read
|
||||
// process.env at load time; mock them (hoisted before imports) so the spec
|
||||
// needs no runtime env. Each mock severs an entire import subtree and still
|
||||
// provides a class usable as a DI token.
|
||||
jest.mock('../customers/customers.service', () => ({ CustomersService: class {} }));
|
||||
jest.mock('../catalog/catalog.service', () => ({ CatalogService: class {} }));
|
||||
jest.mock('../inputs/inputs.service', () => ({ InputsService: class {} }));
|
||||
jest.mock('./pipeline-tables.service', () => ({ PipelineTablesService: class {} }));
|
||||
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { PlatformApiController } from './platform-api.controller';
|
||||
import { PlatformApiService } from './platform-api.service';
|
||||
import { PipelineTablesService } from './pipeline-tables.service';
|
||||
import { ElasticsearchService } from '../../services/elasticsearch';
|
||||
import { DynamoDBService } from '../../services/dynamodb';
|
||||
import { CustomersService } from '../customers/customers.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { InputsService } from '../inputs/inputs.service';
|
||||
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
const mockUser = { customer_id: 'c1', customer_name: 'cust', user_id: 'u1' } as never;
|
||||
|
||||
// The table routes are thin: validate the body, delegate to
|
||||
// PipelineTablesService (covered in pipeline-tables.service.spec.ts).
|
||||
describe('PlatformApiController - table routes', () => {
|
||||
let controller: PlatformApiController;
|
||||
let tables: { removeTable: jest.Mock; removeTables: jest.Mock; addTable: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
tables = { removeTable: jest.fn(), removeTables: jest.fn(), addTable: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [PlatformApiController],
|
||||
providers: [
|
||||
{ provide: PlatformApiService, useValue: {} },
|
||||
{ provide: ElasticsearchService, useValue: {} },
|
||||
{ provide: DynamoDBService, useValue: {} },
|
||||
{ provide: CustomersService, useValue: {} },
|
||||
{ provide: CatalogService, useValue: {} },
|
||||
{ provide: InputsService, useValue: {} },
|
||||
{ provide: PipelineTablesService, useValue: tables },
|
||||
{ provide: DadosferaLogger, useValue: { logger } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<PlatformApiController>(PlatformApiController);
|
||||
});
|
||||
|
||||
it('deleteTable delegates', async () => {
|
||||
tables.removeTable.mockResolvedValue({ name: 'pedidos', is_deleted: true });
|
||||
await expect(controller.deleteTable('pid', 'iid', { table_name: 'pedidos' }, mockUser))
|
||||
.resolves.toEqual({ name: 'pedidos', is_deleted: true });
|
||||
expect(tables.removeTable).toHaveBeenCalledWith('pid', 'iid', 'pedidos', mockUser);
|
||||
});
|
||||
|
||||
it('deleteTables delegates', async () => {
|
||||
tables.removeTables.mockResolvedValue({ deleted: true });
|
||||
await controller.deleteTables('pid', 'iid', { table_names: ['a', 'b'] }, mockUser);
|
||||
expect(tables.removeTables).toHaveBeenCalledWith('pid', 'iid', ['a', 'b'], mockUser);
|
||||
});
|
||||
|
||||
it('deleteTables rejects an empty table_names before touching anything', async () => {
|
||||
await expect(controller.deleteTables('pid', 'iid', { table_names: [] }, mockUser))
|
||||
.rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tables.removeTables).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addTable delegates', async () => {
|
||||
const body = {
|
||||
table_name: 'orders',
|
||||
table_schema: 'public',
|
||||
primary_keys: ['id'],
|
||||
destinations: {
|
||||
raw: { table_schema: 'raw', table_name: 'orders' },
|
||||
qualify: { table_schema: 'qualify', table_name: 'orders' },
|
||||
},
|
||||
};
|
||||
tables.addTable.mockResolvedValue({ job_ids: ['p_2'], skipped: [] });
|
||||
await expect(controller.addTable('pid', 'iid', body, mockUser)).resolves.toEqual({ job_ids: ['p_2'], skipped: [] });
|
||||
expect(tables.addTable).toHaveBeenCalledWith('pid', 'iid', body, mockUser);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ValidationTableDTO {
|
||||
@ApiProperty()
|
||||
tables: Array<{
|
||||
table_name: string;
|
||||
table_schema: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class DeleteTableBody {
|
||||
@ApiProperty()
|
||||
table_name: string;
|
||||
}
|
||||
|
||||
export class DeleteTablesBody {
|
||||
@ApiProperty({ type: [String] })
|
||||
table_names: string[];
|
||||
}
|
||||
|
||||
export class CdcTableDestination {
|
||||
@ApiProperty()
|
||||
table_schema: string;
|
||||
@ApiProperty()
|
||||
table_name: string;
|
||||
}
|
||||
|
||||
export class CdcTableDestinations {
|
||||
@ApiProperty({ type: CdcTableDestination })
|
||||
raw: CdcTableDestination;
|
||||
@ApiProperty({ type: CdcTableDestination })
|
||||
qualify: CdcTableDestination;
|
||||
}
|
||||
|
||||
export class CdcColumnBody {
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
@ApiProperty()
|
||||
type: string;
|
||||
@ApiProperty()
|
||||
is_primary_key: boolean;
|
||||
}
|
||||
|
||||
export class AddCdcTableBody {
|
||||
@ApiProperty()
|
||||
table_name: string;
|
||||
@ApiProperty()
|
||||
table_schema: string;
|
||||
@ApiProperty({ type: [String] })
|
||||
primary_keys: string[];
|
||||
@ApiProperty({ type: CdcTableDestinations })
|
||||
destinations: CdcTableDestinations;
|
||||
// Iceberg destination only (protospack CdcTable.iceberg_table_name);
|
||||
// absent for snowflake, back-compat.
|
||||
@ApiPropertyOptional()
|
||||
iceberg_table_name?: string;
|
||||
// Per-table deduped (qualify) Iceberg table name; absent => same as raw.
|
||||
@ApiPropertyOptional()
|
||||
iceberg_qualify_table_name?: string;
|
||||
// Source column schema for iceberg deduped table pre-create.
|
||||
@ApiPropertyOptional({ type: [CdcColumnBody] })
|
||||
columns?: CdcColumnBody[];
|
||||
// Columns the user chose to ignore -> Debezium column.exclude.list.
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
column_exclude_list?: string[];
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { PlatformApiController } from './platform-api.controller';
|
||||
import { PlatformApiService } from './platform-api.service';
|
||||
import { PipelineTablesService } from './pipeline-tables.service';
|
||||
import { ElasticsearchModule } from '../../services/elasticsearch';
|
||||
import { DynamoDBModule } from '../../services/dynamodb';
|
||||
import { CustomersModule } from '../customers/customers.module';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { InputsModule } from '../inputs/inputs.module';
|
||||
import { PipelinesV2Module } from '../pipelinesV2/pipelines.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ElasticsearchModule,
|
||||
DynamoDBModule,
|
||||
CustomersModule,
|
||||
CatalogModule,
|
||||
InputsModule,
|
||||
forwardRef(() => PipelinesV2Module),
|
||||
],
|
||||
controllers: [PlatformApiController],
|
||||
providers: [PlatformApiService, PipelineTablesService, DadosferaLogger],
|
||||
exports: [PlatformApiService],
|
||||
})
|
||||
export class PlatformApiModule {}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { Injectable, Inject, HttpException } from '@nestjs/common';
|
||||
import { SignatureV4 } from '@aws-sdk/signature-v4';
|
||||
import { Sha256 } from '@aws-crypto/sha256-js';
|
||||
import { defaultProvider } from '@aws-sdk/credential-provider-node';
|
||||
import axios, { AxiosResponse, Method } from 'axios';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { RequestUser } from '../../decorators/user.decorator';
|
||||
import { PLATFORM_API_CONFIG } from './platform-api.config';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformApiService {
|
||||
private signer: SignatureV4;
|
||||
private logger: any;
|
||||
|
||||
constructor(
|
||||
@Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
this.signer = new SignatureV4({
|
||||
service: 'execute-api',
|
||||
region: PLATFORM_API_CONFIG.region,
|
||||
credentials: defaultProvider(),
|
||||
sha256: Sha256,
|
||||
});
|
||||
}
|
||||
|
||||
async proxy(
|
||||
method: string,
|
||||
path: string,
|
||||
user: RequestUser,
|
||||
body?: any,
|
||||
query?: Record<string, string>,
|
||||
): Promise<any> {
|
||||
const baseUrl = PLATFORM_API_CONFIG.getUrl();
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
|
||||
// Add query params
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
host: url.hostname,
|
||||
'content-type': 'application/json',
|
||||
// Forward user context headers
|
||||
// Note: platform-api expects customer_name in the 'customer_id' header (contract inconsistency)
|
||||
'customer_id': user.customer_name || '',
|
||||
'customer_name': user.customer_name || '',
|
||||
'x-user-id': user.user_id || '',
|
||||
'x-username': user.username || '',
|
||||
'x-customer-tier': user.customer_tier || '',
|
||||
'x-customer-id': user.customer_id || '',
|
||||
};
|
||||
|
||||
const requestToSign = {
|
||||
method: method.toUpperCase(),
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||||
path: url.pathname + url.search,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
|
||||
this.logger.info('Proxying request to platform-api', {
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
customer_id: user.customer_id,
|
||||
user_id: user.user_id,
|
||||
});
|
||||
|
||||
try {
|
||||
// Sign with IAM v4
|
||||
const signedRequest = await this.signer.sign(requestToSign);
|
||||
|
||||
const response: AxiosResponse = await axios({
|
||||
method: method as Method,
|
||||
url: url.href,
|
||||
headers: signedRequest.headers as Record<string, string>,
|
||||
data: body,
|
||||
timeout: PLATFORM_API_CONFIG.timeout,
|
||||
validateStatus: () => true, // Don't throw on non-2xx
|
||||
});
|
||||
|
||||
// Propagate non-2xx responses as HttpExceptions
|
||||
if (response.status >= 400) {
|
||||
this.logger.error('Platform API upstream error' + JSON.stringify({
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
}));
|
||||
throw new HttpException(response.data, response.status);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Platform API proxy error', {
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
});
|
||||
|
||||
this.logger.error(error)
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
throw new HttpException(error.response.data, error.response.status);
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new HttpException('Platform API service unavailable', 503);
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
|
||||
throw new HttpException('Platform API request timeout', 504);
|
||||
}
|
||||
|
||||
throw new HttpException('Internal server error', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export type ReleaseNoteDTO = {
|
||||
id: string;
|
||||
date: string;
|
||||
tag: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
expiryDate: string;
|
||||
content: string;
|
||||
showEmojis: boolean;
|
||||
image?: string;
|
||||
link?: string;
|
||||
linkText?: string;
|
||||
};
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { ReleaseNoteController } from './release_note.controller';
|
||||
import { ReleaseNoteService } from './release_note.service';
|
||||
|
||||
const logger = {
|
||||
info: (...args) => args,
|
||||
error: (...args) => args,
|
||||
};
|
||||
|
||||
describe('ReleaseNoteController', () => {
|
||||
let controller: ReleaseNoteController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ReleaseNoteController],
|
||||
providers: [
|
||||
ReleaseNoteService,
|
||||
{
|
||||
provide: DadosferaLogger,
|
||||
useValue: { logger },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ReleaseNoteController>(ReleaseNoteController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { ReleaseNoteService } from './release_note.service';
|
||||
import { Authenticated } from 'src/decorators/authentication.decorator';
|
||||
import { Language } from 'src/decorators/language.decorator';
|
||||
import { LanguageEnum } from 'src/utils/languages.enum';
|
||||
import DadosferaLogger from '@dadosfera/dadosfera-logs';
|
||||
|
||||
@Controller('release_note')
|
||||
@Authenticated()
|
||||
export class ReleaseNoteController {
|
||||
logger: DadosferaLogger;
|
||||
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
private readonly releaseNoteService: ReleaseNoteService,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getLatestReleaseNote(@Language() language: LanguageEnum) {
|
||||
this.logger.info(`Fetching latest release note for language: ${language}`);
|
||||
return await this.releaseNoteService.getLatestReleaseNote(language);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReleaseNoteService } from './release_note.service';
|
||||
import { ReleaseNoteController } from './release_note.controller';
|
||||
import DadosferaLogger from '@dadosfera/dadosfera-logs';
|
||||
|
||||
@Module({
|
||||
controllers: [ReleaseNoteController],
|
||||
providers: [ReleaseNoteService, DadosferaLogger]
|
||||
})
|
||||
export class ReleaseNoteModule {}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import { ReleaseNoteService } from './release_note.service';
|
||||
|
||||
const logger = {
|
||||
info: (...args) => args,
|
||||
error: (...args) => args,
|
||||
};
|
||||
|
||||
describe('ReleaseNoteService', () => {
|
||||
let service: ReleaseNoteService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ReleaseNoteService,
|
||||
{
|
||||
provide: DadosferaLogger,
|
||||
useValue: { logger },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ReleaseNoteService>(ReleaseNoteService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { LanguageEnum } from 'src/utils/languages.enum';
|
||||
import { ReleaseNoteDTO } from './dto/release_note.dto';
|
||||
import DadosferaLogger from '@dadosfera/dadosfera-logs';
|
||||
|
||||
@Injectable()
|
||||
export class ReleaseNoteService {
|
||||
client: AxiosInstance;
|
||||
logger: DadosferaLogger;
|
||||
|
||||
constructor(
|
||||
@Inject(DadosferaLogger)
|
||||
dadosferaLogger: DadosferaLogger,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
this.client = axios.create({
|
||||
baseURL: process.env.FIREBASE_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
async getLatestReleaseNote(lang: LanguageEnum) {
|
||||
try {
|
||||
const lng = lang.split('-');
|
||||
const language = lng[0] + '-' + lng[1].toUpperCase();
|
||||
|
||||
const endpoint = `/release_note/${language}.json`;
|
||||
const {
|
||||
data,
|
||||
status,
|
||||
config
|
||||
} = await this.client.get<ReleaseNoteDTO>(endpoint)
|
||||
this.logger.info(`Fetched release note for language: ${lang} with status: ${status}`);
|
||||
this.logger.info(`Request URL: ${config.baseURL}/${config.url}`);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error fetching release note: ${error.message}`);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
this.logger.error(`Axios error details: ${error.toJSON()}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -229,33 +229,27 @@ export class RolesService {
|
||||
const [roleTreated] = this.getRolesPermissionsName([role.role]);
|
||||
return { role: roleTreated };
|
||||
}
|
||||
|
||||
getRolesPermissionsName(roles: GetRolesPermissionsName[]): RoleDto[] {
|
||||
const newRoles: RoleDto[] = [];
|
||||
for (const role of roles) {
|
||||
const newRole: RoleDto = this.formatRole(role);
|
||||
const allPermissions = this.permissionsService.getAllPermissions(
|
||||
this.language,
|
||||
);
|
||||
const newPermissions = role.permissions.map((p) => {
|
||||
const permission = allPermissions.find((per) => per.seqid === p.seqid);
|
||||
return {
|
||||
...p,
|
||||
name: permission.name,
|
||||
id: p.seqid,
|
||||
};
|
||||
});
|
||||
const newRole: RoleDto = {
|
||||
...role,
|
||||
permissions: newPermissions,
|
||||
isPublic: role.isPublic,
|
||||
};
|
||||
newRoles.push(newRole);
|
||||
}
|
||||
return newRoles;
|
||||
}
|
||||
|
||||
private formatRole(role: GetRolesPermissionsName): RoleDto {
|
||||
const allPermissions = this.permissionsService.getAllPermissions(
|
||||
this.language
|
||||
);
|
||||
const newPermissions = role.permissions.map((p) => {
|
||||
const permission = allPermissions.find((per) => per.seqid === p.seqid);
|
||||
return {
|
||||
...p,
|
||||
name: permission.name,
|
||||
id: p.seqid,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...role,
|
||||
permissions: newPermissions,
|
||||
isPublic: role.isPublic,
|
||||
};;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export const STORAGE_EXPLORER_CONFIG = {
|
||||
getUrl: (customerName: string): string => {
|
||||
const urlTemplate = process.env.STORAGE_EXPLORER_API_URL;
|
||||
if (!urlTemplate) {
|
||||
throw new Error('STORAGE_EXPLORER_API_URL environment variable is not set');
|
||||
}
|
||||
// Replace {customer_id} placeholder with actual customer ID
|
||||
// For local: http://172.17.0.1:8000/api (no placeholder)
|
||||
// For prod: https://storage-explorer-{customer_id}.dadosfera.ai/api
|
||||
return urlTemplate.replace('{customer}', customerName);
|
||||
},
|
||||
timeout: parseInt(process.env.STORAGE_EXPLORER_TIMEOUT || '30000', 10),
|
||||
};
|
||||
@@ -1,383 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Inject,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
Headers,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiConsumes } from '@nestjs/swagger';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
import FormData from 'form-data';
|
||||
|
||||
import {
|
||||
Authenticated,
|
||||
RequireAllPermissions,
|
||||
} from '../../decorators/authentication.decorator';
|
||||
import { User, RequestUser } from '../../decorators/user.decorator';
|
||||
import { StorageExplorerService } from './storage-explorer.service';
|
||||
import { PERMISSIONS_GROUPS } from '../../authentication/permissions.enum';
|
||||
|
||||
@ApiTags('Storage Explorer')
|
||||
@Controller('storage-explorer')
|
||||
@Authenticated()
|
||||
export class StorageExplorerController {
|
||||
private logger: any;
|
||||
|
||||
constructor(
|
||||
private readonly storageExplorerService: StorageExplorerService,
|
||||
@Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE OPERATIONS
|
||||
// ============================================
|
||||
|
||||
@ApiOperation({ summary: 'Validate table name in PostgreSQL and Snowflake' })
|
||||
@Post('tables/validate-name')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE)
|
||||
async validateTableName(
|
||||
@Body() body: any,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'POST',
|
||||
'/tables/validate-name',
|
||||
user,
|
||||
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Create a new table' })
|
||||
@Post('tables')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE)
|
||||
async createTable(
|
||||
@Body() body: any,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'POST',
|
||||
'/tables/',
|
||||
user,
|
||||
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'List all tables with pagination' })
|
||||
@Get('tables')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async listTables(
|
||||
@Query('page') page: number,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
'/tables/',
|
||||
user,
|
||||
|
||||
undefined,
|
||||
{ page },
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get table details by ID' })
|
||||
@Get('tables/:tableId')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async getTable(
|
||||
@Param('tableId') tableId: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
`/tables/${tableId}`,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Link a dataset to a table' })
|
||||
@Post('tables/:tableId/datasets/:datasetId')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE)
|
||||
async linkDatasetToTable(
|
||||
@Param('tableId') tableId: string,
|
||||
@Param('datasetId') datasetId: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'POST',
|
||||
`/tables/${tableId}/datasets/${datasetId}`,
|
||||
user,
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get all datasets linked to a table' })
|
||||
@Get('tables/:tableId/datasets')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async getTableDatasets(
|
||||
@Param('tableId') tableId: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
`/tables/${tableId}/datasets`,
|
||||
user,
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get table schema' })
|
||||
@Get('tables/:tableId/schema')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async getTableSchema(
|
||||
@Param('tableId') tableId: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
`/tables/${tableId}/schema`,
|
||||
user,
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Validate schema compatibility between table and dataset' })
|
||||
@Post('tables/:tableId/validate-compatibility/:datasetId')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async validateSchemaCompatibility(
|
||||
@Param('tableId') tableId: string,
|
||||
@Param('datasetId') datasetId: string,
|
||||
@User() user: RequestUser,
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'POST',
|
||||
`/tables/${tableId}/validate-compatibility/${datasetId}`,
|
||||
user,
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATASET OPERATIONS
|
||||
// ============================================
|
||||
|
||||
@ApiOperation({ summary: 'Get dataset preview data' })
|
||||
@Get('datasets/:datasetId/preview')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async getDatasetPreview(
|
||||
@Param('datasetId') datasetId: string,
|
||||
@Query('limit') limit: number,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
`/datasets/${datasetId}/preview`,
|
||||
user,
|
||||
|
||||
undefined,
|
||||
{ limit },
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get dataset schema information' })
|
||||
@Get('datasets/:datasetId/schema')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async getDatasetSchema(
|
||||
@Param('datasetId') datasetId: string,
|
||||
@Query('force_refresh') forceRefresh: boolean,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
`/datasets/${datasetId}/schema`,
|
||||
user,
|
||||
|
||||
undefined,
|
||||
{ force_refresh: forceRefresh },
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'List all datasets for a specific upload' })
|
||||
@Get('datasets/upload/:uploadId')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async listDatasetsByUpload(
|
||||
@Param('uploadId') uploadId: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
`/datasets/upload/${uploadId}`,
|
||||
user,
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Refresh dataset schema with new parsing options (Excel)' })
|
||||
@Put('datasets/:datasetId/refresh-schema')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE)
|
||||
async refreshDatasetSchema(
|
||||
@Param('datasetId') datasetId: string,
|
||||
@Body() body: any,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'PUT',
|
||||
`/datasets/${datasetId}/refresh-schema`,
|
||||
user,
|
||||
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// STORAGE OPERATIONS
|
||||
// ============================================
|
||||
|
||||
@ApiOperation({ summary: 'List file explorer uploads with pagination' })
|
||||
@Get('storage/uploads/history')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async listFileExplorerUploads(
|
||||
@Query('page') page: number,
|
||||
@Query('limit') limit: number,
|
||||
@Query('folder_path') folderPath: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
'/storage/uploads/history',
|
||||
user,
|
||||
|
||||
undefined,
|
||||
{ page, limit, folder_path: folderPath },
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Browse folders and files in storage' })
|
||||
@Get('storage/browse')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async browseStorage(
|
||||
@Query('path') path: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
'/storage/browse',
|
||||
user,
|
||||
|
||||
undefined,
|
||||
{ path },
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Upload multiple files to storage' })
|
||||
@Post('storage/upload/batch')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FilesInterceptor('files'))
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE)
|
||||
async batchUpload(
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
@Body('folder_path') folderPath: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
// Create FormData to forward files to storage-explorer API
|
||||
const formData = new FormData();
|
||||
|
||||
// Add files
|
||||
if (files && files.length > 0) {
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file.buffer, {
|
||||
filename: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add folder_path
|
||||
if (folderPath) {
|
||||
formData.append('folder_path', folderPath);
|
||||
}
|
||||
|
||||
return this.storageExplorerService.proxyFormData(
|
||||
'POST',
|
||||
'/storage/upload/batch',
|
||||
user,
|
||||
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Create a new folder in storage' })
|
||||
@Post('storage/folder/create')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE)
|
||||
async createFolder(
|
||||
@Body() body: any,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'POST',
|
||||
'/storage/folder/create',
|
||||
user,
|
||||
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Download a file from storage' })
|
||||
@Get('storage/download')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async downloadFile(
|
||||
@Query('file_path') filePath: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
'/storage/download',
|
||||
user,
|
||||
|
||||
undefined,
|
||||
{ file_path: filePath },
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get detailed file metadata' })
|
||||
@Get('storage/metadata')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ)
|
||||
async getFileMetadata(
|
||||
@Query('file_path') filePath: string,
|
||||
@User() user: RequestUser,
|
||||
|
||||
) {
|
||||
return this.storageExplorerService.proxy(
|
||||
'GET',
|
||||
'/storage/metadata',
|
||||
user,
|
||||
undefined,
|
||||
{ file_path: filePath },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { StorageExplorerController } from './storage-explorer.controller';
|
||||
import { StorageExplorerService } from './storage-explorer.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [StorageExplorerController],
|
||||
providers: [StorageExplorerService, DadosferaLogger],
|
||||
exports: [StorageExplorerService],
|
||||
})
|
||||
export class StorageExplorerModule {}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { Injectable, Inject, HttpException } from '@nestjs/common';
|
||||
import axios, { AxiosResponse, Method } from 'axios';
|
||||
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
|
||||
|
||||
import { RequestUser } from '../../decorators/user.decorator';
|
||||
import { STORAGE_EXPLORER_CONFIG } from './storage-explorer.config';
|
||||
|
||||
@Injectable()
|
||||
export class StorageExplorerService {
|
||||
private logger: any;
|
||||
|
||||
constructor(
|
||||
@Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger,
|
||||
) {
|
||||
this.logger = dadosferaLogger.logger;
|
||||
}
|
||||
|
||||
async proxy(
|
||||
method: string,
|
||||
path: string,
|
||||
user: RequestUser,
|
||||
body?: any,
|
||||
query?: Record<string, any>
|
||||
): Promise<any> {
|
||||
// Validate customer_id is present for multi-tenant isolation
|
||||
if (!user.customer_id) {
|
||||
throw new HttpException('Customer ID is required for storage operations', 400);
|
||||
}
|
||||
|
||||
// Get customer-specific storage-explorer URL
|
||||
const baseUrl = STORAGE_EXPLORER_CONFIG.getUrl(user.customer_name);
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
|
||||
// Add query params
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
this.logger.info('Proxying request to storage-explorer', {
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
customer_id: user.customer_id,
|
||||
storage_url: baseUrl,
|
||||
user_id: user.user_id,
|
||||
});
|
||||
|
||||
try {
|
||||
const response: AxiosResponse = await axios({
|
||||
method: method as Method,
|
||||
url: url.href,
|
||||
headers,
|
||||
data: body,
|
||||
timeout: STORAGE_EXPLORER_CONFIG.timeout,
|
||||
validateStatus: () => true, // Don't throw on non-2xx
|
||||
});
|
||||
|
||||
// Propagate non-2xx responses as HttpExceptions
|
||||
if (response.status >= 400) {
|
||||
throw new HttpException(response.data, response.status);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Storage Explorer API proxy error', {
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
path,
|
||||
storage_url: baseUrl,
|
||||
method: method.toUpperCase(),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
throw new HttpException(error.response.data, error.response.status);
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new HttpException('Storage Explorer API service unavailable', 503);
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
|
||||
throw new HttpException('Storage Explorer API request timeout', 504);
|
||||
}
|
||||
|
||||
throw new HttpException('Internal server error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy with file upload support (multipart/form-data)
|
||||
*/
|
||||
async proxyFormData(
|
||||
method: string,
|
||||
path: string,
|
||||
user: RequestUser,
|
||||
formData: any,
|
||||
query?: Record<string, any>,
|
||||
): Promise<any> {
|
||||
// Validate customer_id is present for multi-tenant isolation
|
||||
if (!user.customer_id) {
|
||||
throw new HttpException('Customer ID is required for storage operations', 400);
|
||||
}
|
||||
|
||||
// Get customer-specific storage-explorer URL
|
||||
const baseUrl = STORAGE_EXPLORER_CONFIG.getUrl(user.customer_name);
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
|
||||
// Add query params
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
// Let axios set Content-Type for multipart/form-data with boundary
|
||||
...formData.getHeaders?.(),
|
||||
};
|
||||
|
||||
this.logger.info('Proxying form data request to storage-explorer', {
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
customer_id: user.customer_id,
|
||||
storage_url: baseUrl,
|
||||
user_id: user.user_id,
|
||||
});
|
||||
|
||||
try {
|
||||
const response: AxiosResponse = await axios({
|
||||
method: method as Method,
|
||||
url: url.href,
|
||||
headers,
|
||||
data: formData,
|
||||
timeout: STORAGE_EXPLORER_CONFIG.timeout,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new HttpException(response.data, response.status);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Storage Explorer API form data proxy error', {
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
path,
|
||||
storage_url: baseUrl,
|
||||
method: method.toUpperCase(),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
throw new HttpException(error.response.data, error.response.status);
|
||||
}
|
||||
|
||||
throw new HttpException('Internal server error', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,24 +124,4 @@ export class ThemeController {
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/:id/theme/reset')
|
||||
@ApiOkResponse({ type: CustomerThemeResponse })
|
||||
async resetTheme(@Param('id') id: string) {
|
||||
this.logger.info('getCustomerTheme with id' + id);
|
||||
|
||||
try {
|
||||
await this.themeService.resetTheme(id);
|
||||
|
||||
return { theme: null };
|
||||
}catch (err) {
|
||||
if (err.details === ErrorCodes.CUSTOMER.NOT_FOUND) {
|
||||
this.logger.error('Error - getCustomerTheme - Expect CUSTOMER.NOT_FOUND');
|
||||
throw new HttpException(err.details, HttpStatus.NOT_FOUND);
|
||||
} else {
|
||||
this.logger.error('Error - getCustomerTheme Unknown Error:' + err?.message);
|
||||
return { theme: null };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -44,18 +44,6 @@ export class ThemeService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
async resetTheme(id: string) {
|
||||
const { theme } = await firstValueFrom(
|
||||
this.themeService.ResetCustomerTheme({
|
||||
id
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
theme
|
||||
}
|
||||
}
|
||||
|
||||
async createThemeByCustomer(id: string, theme: CustomerThemeRequest & Files) {
|
||||
if (!id) {
|
||||
this.logger.error('Error - saveCustomertheme - not found id:' + id);
|
||||
|
||||
+2
-5
@@ -1,8 +1,5 @@
|
||||
export interface Info {
|
||||
user_id: string;
|
||||
customer_id: string;
|
||||
customer: string;
|
||||
}
|
||||
import { Info } from '@dadosfera/protospack/dist/lib/interfaces';
|
||||
|
||||
export interface ICreateTransformationsRequest {
|
||||
transformations: Transformation[];
|
||||
info: Info;
|
||||
|
||||
@@ -38,12 +38,6 @@ export class User {
|
||||
department?: string;
|
||||
@ApiProperty()
|
||||
hierarchy?: string;
|
||||
@ApiProperty()
|
||||
bio?: string;
|
||||
@ApiProperty()
|
||||
companyName?: string;
|
||||
@ApiProperty()
|
||||
personalSite?: string;
|
||||
@ApiPropertyOptional()
|
||||
customer?: Customer;
|
||||
@ApiProperty()
|
||||
@@ -64,8 +58,6 @@ export class UserNoRolesAndCustomer extends OmitType(UserNoRoles, [
|
||||
export class IUserByCustomer extends OmitType(User, ['customer']) {
|
||||
@ApiPropertyOptional()
|
||||
permissions?: string[];
|
||||
@ApiPropertyOptional()
|
||||
authProvider?: string;
|
||||
}
|
||||
|
||||
export class CreateUserReq {
|
||||
@@ -117,12 +109,6 @@ export class UpdateUserReq {
|
||||
@ApiPropertyOptional()
|
||||
hierarchy?: string;
|
||||
@ApiPropertyOptional()
|
||||
bio?: string;
|
||||
@ApiPropertyOptional()
|
||||
personalSite?: string;
|
||||
@ApiPropertyOptional()
|
||||
companyName?: string;
|
||||
@ApiPropertyOptional()
|
||||
roleNames?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequireAllPermissions(PERMISSIONS_GROUPS.USERS.permissions.ADMIN)
|
||||
@ApiOkResponse({ type: UpdateUserRes })
|
||||
async updateUser(
|
||||
@User() user: RequestUser,
|
||||
@@ -273,18 +274,6 @@ export class UsersController {
|
||||
@Param('id') id: string,
|
||||
@Language() language: LanguageEnum,
|
||||
) {
|
||||
|
||||
const isSameUser = user.user_id === id;
|
||||
const isSuperAdmin = user.permissions.includes(PERMISSIONS_GROUPS.USERS.permissions.ADMIN.seqid)
|
||||
if (!isSameUser && !isSuperAdmin) {
|
||||
throw new ErrorBuilder(ErrorCodes.AUTH.FORBIDDEN);
|
||||
}
|
||||
|
||||
if (isSameUser && !isSuperAdmin && body.roleNames) {
|
||||
// Prevent users from updating their own roles
|
||||
delete body.roleNames;
|
||||
}
|
||||
|
||||
this.logger.info('updateUser', { user });
|
||||
this.userService.setLanguage(language);
|
||||
return await this.userService.updateUser(body, id, user.customer_id);
|
||||
|
||||
@@ -125,7 +125,6 @@ export class UsersService implements OnModuleInit {
|
||||
return { permissions };
|
||||
});
|
||||
res.user.permissions = permissions;
|
||||
res.user.authProvider = process.env.AUTH_PROVIDER || 'cognito';
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -149,23 +148,20 @@ export class UsersService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async updateUser(req: UpdateUserReq, id: string, customerId: string) {
|
||||
const { roleNames, ...updateUserDTO } = req;
|
||||
if (roleNames && roleNames.length > 0) {
|
||||
const { department, hierarchy, jobTitle, name, roleNames, email } = req;
|
||||
if (roleNames) {
|
||||
await this.setRoles({ roleNames, userId: id }, customerId);
|
||||
}
|
||||
|
||||
const { user } = await lastValueFrom(
|
||||
this.usersClientService.UserUpdate({
|
||||
department: updateUserDTO.department,
|
||||
email: updateUserDTO.email,
|
||||
hierarchy: updateUserDTO.hierarchy,
|
||||
jobTitle: updateUserDTO.jobTitle,
|
||||
name: updateUserDTO.name,
|
||||
bio: updateUserDTO.bio,
|
||||
companyName: updateUserDTO.companyName,
|
||||
personalSite: updateUserDTO.personalSite,
|
||||
name,
|
||||
customerId,
|
||||
id,
|
||||
department,
|
||||
hierarchy,
|
||||
jobTitle,
|
||||
email,
|
||||
metabaseUserId: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,10 +14,7 @@ export class ValidationPipe implements PipeTransform<any> {
|
||||
return value;
|
||||
}
|
||||
const object = plainToInstance(metatype, value);
|
||||
const errors = await validate(object, {
|
||||
forbidUnknownValues: false,
|
||||
whitelist: true,
|
||||
});
|
||||
const errors = await validate(object);
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors.map((err) => err.constraints);
|
||||
throw new BadRequestException(errorMessages);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user