Compare commits

..
Author SHA1 Message Date
RafaelandClaude Opus 4.6 051fb6e4dd FIX: uppercase table_name and table_schema in Nimbus rename calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:39:55 -03:00
RafaelandClaude Opus 4.6 e3099aa2b2 FIX: uppercase table_name and table_schema in ES lookup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:17:56 -03:00
RafaelandClaude Opus 4.6 12c61d9b5d FIX: rename-tables proxy path and ES lookup
- Fix proxy path: /jobs/jdbc/:jobId/rename-tables → /jobs/:jobId/rename-tables
- Remove pipeline_id from ES data asset lookup, search by table_name + table_schema only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:08:39 -03:00
Rafael 99c2a9ecf5 UPDATE: force deployment of maestro 2026-03-12 18:02:49 -03:00
Rafael Santana c0f75d241f Merge pull request #449 from dadosfera/feat/rename-tables-catalog-sync
Feat/rename tables catalog sync
2026-03-12 17:57:44 -03:00
Rafael Santana 1e0fb78dff Merge branch 'beta' into feat/rename-tables-catalog-sync 2026-03-12 17:57:37 -03:00
Marcos Rodrigues Silva b70d37423d Merge pull request #454 from dadosfera/fix/header-validation
FIX: types
2026-03-11 10:27:31 -03:00
marcos-silva-rodrigues 4ddd5edcfd FIX: types 2026-03-11 10:26:33 -03:00
Marcos Rodrigues Silva 3bcbba9581 Merge pull request #453 from dadosfera/fix/header-validation
Fix/header validation
2026-03-10 17:35:31 -03:00
marcos-silva-rodrigues 67d47a9642 FIX: correct header 2026-03-10 17:35:05 -03:00
Marcos Rodrigues Silva 6f9c967c96 Merge pull request #452 from dadosfera/feat/qualify
FIX: stringify headers
2026-03-10 17:17:48 -03:00
marcos-silva-rodrigues b8bdc5beea FIX: stringify headers 2026-03-10 16:00:39 -03:00
Marcos Rodrigues Silva 269f70b309 Merge pull request #451 from dadosfera/feat/qualify
FIX: ghost commit
2026-03-10 15:22:36 -03:00
marcos-silva-rodrigues 93f452ae05 FIX: ghost commit 2026-03-10 15:19:18 -03:00
Marcos Rodrigues Silva e39378229f Merge pull request #450 from dadosfera/feat/qualify
feat: list headers
2026-03-10 14:43:43 -03:00
marcos-silva-rodrigues 61a4f724ef feat: list headers 2026-03-10 14:43:11 -03:00
RafaelandClaude Opus 4.6 38a9e21f5f feat: add rename-tables endpoint with catalog sync and rollback
Add POST /platform/jobs/:jobId/rename-tables that renames Snowflake
tables via platform-api and syncs the rename to Elasticsearch and
Nimbus (table-metadata, column-metadata, data-preview). If catalog
sync fails, all completed catalog steps are rolled back in reverse
order and the Snowflake rename is reverted.

- Support any connector type (jdbc, singer, s3) via getJobByAnyConnectorType
- Resolve old table names from output_config (raw/qualify)
- Skip qualify sync when output_config.qualify has no table_name
- Add findDataAssetByPipelineAndTable and updateDataAsset to ElasticsearchService
- Add renameTableOnNimbus, renameColumnMetadataOnNimbus, renameDataPreviewOnNimbus to CatalogService
- Add upstream error logging to PlatformApiService

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:29:12 -03:00
Marcos Rodrigues Silva 1a2153d62f Merge pull request #448 from dadosfera/fix/proxy-urls
Fix/proxy urls
2026-02-20 16:03:05 -03:00
Marcos Rodrigues Silva d25bfd147c Merge pull request #447 from dadosfera/fix/proxy-urls
Fix/proxy urls
2026-02-20 14:32:50 -03:00
marcos-silva-rodrigues d4ba45fd03 FIX: storage url 2026-02-20 14:28:55 -03:00
marcos-silva-rodrigues 82f1035a5e FIX: update storage port 2026-02-20 14:28:36 -03:00
Marcos Rodrigues Silva 6c57bac235 Merge pull request #445 from dadosfera/feat/qualify
FIX: storage url
2026-02-18 15:43:13 -03:00
marcos-silva-rodrigues cf8eed35a3 FIX: storage url 2026-02-18 15:32:05 -03:00
Marcos Rodrigues Silva 55fc85c544 Merge pull request #444 from dadosfera/feat/qualify
FIX: update storage port
2026-02-16 18:12:27 -03:00
marcos-silva-rodrigues 72ed637640 FIX: update storage port 2026-02-16 18:11:49 -03:00
Marcos Rodrigues Silva 890364f597 Merge pull request #443 from dadosfera/feat/qualify
Feat/qualify
2026-02-13 11:27:41 -03:00
marcos-silva-rodrigues 30eec733b2 FEAT: update proto 2026-02-13 10:01:46 -03:00
marcos-silva-rodrigues 30a41ba144 FEAT: qualify route update 2026-02-12 17:48:05 -03:00
Marcos Rodrigues Silva 2dc032e7e7 Merge pull request #442 from dadosfera/feat/qualify
FIX: platform routes
2026-02-05 10:33:39 -03:00
marcos-silva-rodrigues 7f5981731f merge 2026-02-05 10:32:07 -03:00
marcos-silva-rodrigues 66309c7bbe FIX: platform routes 2026-02-05 10:29:26 -03:00
Marcos Rodrigues Silva 15048eaf8a Merge pull request #441 from dadosfera/feat/qualify
FEAT: qualify contract
2026-02-04 17:09:19 -03:00
marcos-silva-rodrigues 0e169a3cbc FIX: protospack version 2026-02-04 16:50:20 -03:00
marcos-silva-rodrigues b5d933eaf3 FEAT: qualify contract 2026-02-04 16:43:49 -03:00
Marcos Rodrigues Silva 6fa9bf861a Merge pull request #440 from dadosfera/fix/proxy-urls
FIX: storage service dns
2026-02-02 16:38:00 -03:00
marcos-silva-rodrigues 9a217dff57 FIX: storage service dns 2026-02-02 16:37:01 -03:00
Marcos Rodrigues Silva e08734c97f Merge pull request #439 from dadosfera/fix/proxy-urls
FIX: send token by storage
2026-02-02 15:39:00 -03:00
marcos-silva-rodrigues cd4382c1ff FIX: send token by storage 2026-02-02 15:37:13 -03:00
Marcos Rodrigues Silva 54b75ce11b Merge pull request #438 from dadosfera/fix/proxy-urls
FIX: storage api
2026-02-02 14:43:00 -03:00
marcos-silva-rodrigues 0e038d0b12 FIX: storage api 2026-02-02 14:41:53 -03:00
Marcos Rodrigues Silva 85234fe0dd Merge pull request #437 from dadosfera/fix/proxy-urls
Fix/proxy urls
2026-02-02 14:28:04 -03:00
marcos-silva-rodrigues c97a02cb17 FIX: remove / 2026-02-02 14:27:08 -03:00
marcos-silva-rodrigues 878ec977b8 FIX: maestro proxy urls 2026-02-02 14:25:13 -03:00
Marcos Rodrigues Silva 8006867bc2 Merge pull request #436 from dadosfera/beta
Beta
2026-01-26 18:03:42 -03:00
Marcos Rodrigues Silva 784b0ef090 Merge pull request #435 from dadosfera/fix/validation-pipe
FIX: Update ValidationPipe for class-validator 0.14.0+ compatibility
2026-01-21 14:46:57 -03:00
RafaelandClaude b736cddf07 FIX: Update ValidationPipe for class-validator 0.14.0+ compatibility
This fix addresses a breaking change introduced in class-validator 0.14.0
where the default for forbidUnknownValues changed from false to true.

Issue:
- POST /connections was returning 400 "an unknown value was passed to the
  validate function" errors
- This occurred because CreateConnectionDto and UpdateConnectionDto have no
  validation decorators, causing class-validator 0.14.0+ to treat them as
  "unknown values"
- Extra fields (like connector_version) in request payloads would fail
  validation

Solution:
- Set forbidUnknownValues: false to allow DTOs without validation decorators
- Set whitelist: true to automatically strip extra properties not defined
  in DTOs
- This maintains backward compatibility while adding security by removing
  unexpected fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:43:43 -03:00
Rafael Santana 3d6328fb0b Merge pull request #434 from dadosfera/fix/class-validator-0.14-compatibility
UPDATE: updating /me to get api key
2026-01-20 17:53:09 -03:00
Rafael 4246c7495e UPDATE: updating /me to get api key 2026-01-20 17:52:34 -03:00
vinicius gadea 3d40746ccb Merge pull request #433 from dadosfera/feat/storage-explorer-api-integration
FEAT: integrate Storage Explorer API with new endpoints and permissions
2026-01-14 09:37:53 -03:00
viniciusgadea 3ab2f8f27d CHORE: remove linkFileToTable endpoint from Storage Explorer controller 2026-01-14 08:51:45 -03:00
viniciusgadea b44d23552c CHORE: remove unecessary header for request 2026-01-14 06:40:52 -03:00
viniciusgadea 8e63757738 FEAT: add STORAGE_EXPLORER_API_URL to deployment and values configuration 2026-01-14 06:26:21 -03:00
viniciusgadea 3b84409003 CHORE: add unique authentication decorator to Storage Explorer controller methods 2026-01-14 06:25:58 -03:00
viniciusgadea 0ce3822300 CHORE: update new endpoint link-table and remove /delete from storage-explorer api 2026-01-13 14:36:43 -03:00
viniciusgadea a2c7ce00db FEAT: integrate Storage Explorer API with new endpoints and permissions 2026-01-13 10:29:38 -03:00
Rafael Santana 121e30ce45 Merge pull request #432 from dadosfera/fix/class-validator-0.14-compatibility
FIX: Update ValidationPipe for class-validator 0.14.0+ compatibility
2026-01-12 16:30:41 -03:00
RafaelandClaude 85c8a4937d FIX: Update ValidationPipe for class-validator 0.14.0+ compatibility
This fix addresses a breaking change introduced in class-validator 0.14.0
where the default for forbidUnknownValues changed from false to true.

Issue:
- POST /connections was returning 400 "an unknown value was passed to the
  validate function" errors
- This occurred because CreateConnectionDto and UpdateConnectionDto have no
  validation decorators, causing class-validator 0.14.0+ to treat them as
  "unknown values"
- Extra fields (like connector_version) in request payloads would fail
  validation

Solution:
- Set forbidUnknownValues: false to allow DTOs without validation decorators
- Set whitelist: true to automatically strip extra properties not defined
  in DTOs
- This maintains backward compatibility while adding security by removing
  unexpected fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-12 16:22:25 -03:00
Rafael Santana 53920f2f3f Merge pull request #431 from dadosfera/feature/mgc-keycloak-migration
Feature/mgc keycloak migration
2026-01-08 18:21:10 -03:00
RafaelandClaude Opus 4.5 8c34914806 FEAT: add AUTH_PROVIDER config to Helm chart
- Add auth_provider to values.yaml (cognito/keycloak)
- Add AUTH_PROVIDER env var to deployment template
- Note: maestro only needs to know which provider is used, duc handles connection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:22:47 -03:00
RafaelandClaude Opus 4.5 1881d07c4a FEAT: add dual auth provider support to Helm chart
- Add auth_provider config to values.yaml (cognito/keycloak)
- Add AUTH_PROVIDER env var to deployment template
- Add conditional Keycloak env vars (URL, realm, client ID, secret)
- Enables parallel deployments with different auth providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:10:17 -03:00
RafaelandClaude Opus 4.5 3b8310fdca CHORE: simplify Dockerfile.local to use npm ci only
Remove protospack tarball fallback logic

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:04:07 -03:00
RafaelandClaude Opus 4.5 52bda8ebe2 FEAT: add authProvider field to user response
- Add authProvider to user entity DTO
- Update user service to include authProvider
- Update auth controller response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:55:36 -03:00
Rafael 275a53dbd1 Merge remote-tracking branch 'origin/beta' into feature/mgc-keycloak-migration 2026-01-06 12:44:05 -03:00
vinicius gadea 9cefdb226d Merge pull request #430 from dadosfera/feat/organization-form
Feat/organization form
2026-01-06 10:52:33 -03:00
Marcos Rodrigues Silva 007f3911ff Merge pull request #429 from dadosfera/beta
Beta
2026-01-05 17:43:34 -03:00
viniciusgadea 8ac0a8a79f REFACTOR: rename 'companySite' to 'personalSite' in user DTOs and related services 2026-01-05 15:43:35 -03:00
viniciusgadea 285de97375 FIX: update organization info field from 'name' to 'companyName' in updateOrganizationInfo method 2026-01-05 12:01:04 -03:00
viniciusgadea e2a7d2b92b FIX: update organization info field from 'name' to 'companyName' 2026-01-05 11:47:20 -03:00
viniciusgadea da23ad76db DOCS: update docs to organization forms 2026-01-02 09:22:27 -03:00
viniciusgadea 55b0961b82 FEAT: add organization info endpoints and DTOs for update and retrieval new organization forms 2026-01-02 09:21:46 -03:00
Rafael Santana d3c5c0fa63 Merge pull request #427 from dadosfera/fix/security-vulnerabilities-high
FIX: resolve HIGH security vulnerabilities (18 → 0)
2025-12-29 11:47:51 -03:00
RafaelandClaude Opus 4.5 5dbc644d1d fix: security vulnerability - upgrade multer to 2.0.2
- Update multer override from 1.4.5-lts.1 to 2.0.2 to fix HIGH vulnerabilities:
  - CVE-2025-47935 (DoS via memory leaks)
  - CVE-2025-47944
  - CVE-2025-48997
  - CVE-2025-7338

Reduces HIGH vulnerabilities from 4 to 0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 11:42:04 -03:00
Marcos Rodrigues Silva 8eddb9e1bf Merge pull request #428 from dadosfera/hotfix/user-patch
Hotfix/user patch
2025-12-26 17:58:28 -03:00
Marcos Rodrigues 76485f929d FIX: user patch route 2025-12-26 17:53:02 -03:00
RafaelandClaude Opus 4.5 8e0182aa50 FEAT: add TOTP support for change password and local build support
- Pass TOTP code to DUC for Keycloak users with MFA
- Add Dockerfile.local for local protospack builds
- Add .dockerignore to exclude node_modules from Docker context

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 19:03:50 -03:00
Rafael Santana a18bdccc09 Merge branch 'beta' into fix/security-vulnerabilities-high 2025-12-23 13:51:25 -03:00
RafaelandClaude b27298501d FIX: resolve HIGH security vulnerabilities (18 → 0)
Upgraded Node.js and dependencies to address critical vulnerabilities:

**Infrastructure:**
- Upgrade Node.js from 18.17-alpine to 20-alpine (Alpine 3.18 → 3.23)
- Upgrade npm to 11.7.0 (includes glob 13.0.0 fix)
- Replace npm ci with npm install, then back to npm ci with npm 11 lock

**Dependencies:**
- Update axios: 0.27.2 → 0.30.2 (fixes 2 CVEs)
- Update body-parser: 1.20.1/1.20.2 → 1.20.3
- Update cross-spawn: 7.0.3 → 7.0.6 (hoisted)
- Update glob: 10.2.4 → 10.5.0 (hoisted)
- Update path-to-regexp: 3.2.0 → 3.3.0 (maintains @nestjs/swagger compatibility)
- Update semver: 7.5.1 → 7.5.2

**Results:**
- HIGH vulnerabilities: 18 → 0 (100% reduction)
- Alpine OS vulnerabilities: 6 → 0 (fixed by Alpine 3.23)
- Node.js vulnerabilities: 12 → 0
- All tests passing
- Production verified working

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 13:48:02 -03:00
vinicius gadea 33ebc91826 Merge pull request #426 from dadosfera/feature/catalog-update
FEAT: Add optional 'docs' field to assets
2025-12-19 08:24:54 -03:00
vinicius gadea 6948156693 Merge branch 'beta' into feature/catalog-update 2025-12-19 08:21:52 -03:00
viniciusgadea 0aaa4384c3 DOCS: Add 'docs' field to schemas in docsfera.json and update protospack-v2 to version 3.38.0-beta.19 in package-lock.json 2025-12-18 17:43:54 -03:00
Marcos Rodrigues Silva 1f9d0c29ec Merge pull request #425 from dadosfera/beta
Beta
2025-12-18 17:42:10 -03:00
viniciusgadea 039c652b28 DOC: Update @dadosfera/protospack-v2 to version 3.38.0-beta.19 in package.json and package-lock.json 2025-12-18 17:11:18 -03:00
viniciusgadea 653d4f53b5 FEAT: Add optional 'docs' field to IDataAsset, IUpdateDataRequest, and ICreateDataAsset 2025-12-18 16:16:00 -03:00
Marcos Rodrigues Silva 31f8c2c1a6 Merge pull request #424 from dadosfera/feature/user-form
FEAT: protected update route with admin seqid or if user request to e…
2025-12-18 12:33:29 -03:00
Marcos Rodrigues 8b93d4e97b FEAT: protected update route with admin seqid or if user request to equal a jwt user id claim 2025-12-18 12:32:53 -03:00
Rafael Santana adeb022818 Merge pull request #423 from dadosfera/fix/reference-column-object-format
FIX: inject customer_id in pipeline execute/pause/unpause routes
2025-12-18 10:53:31 -03:00
RafaelandClaude Opus 4.5 f61c241dde FIX: inject customer_id in pipeline execute/pause/unpause routes
Users no longer need to provide customer_id in the request body for
execute, pause, and unpause pipeline operations - it's now automatically
injected from the authenticated user's session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:30:09 -03:00
Marcos Rodrigues Silva e326cab44d Merge pull request #422 from dadosfera/feature/user-form
FIX: send new user info
2025-12-18 10:29:09 -03:00
Marcos Rodrigues 61109f8ae9 FIX: send new user info 2025-12-18 10:24:29 -03:00
Marcos Rodrigues Silva 3f8dc5cabe Merge pull request #421 from dadosfera/feature/user-form
Feature/user form
2025-12-18 09:53:12 -03:00
Marcos Rodrigues 7f5d157739 FEAT: update protospack version 2025-12-18 09:52:11 -03:00
Marcos Rodrigues bce73fb11f FEAT: update user route 2025-12-17 17:24:03 -03:00
Rafael Santana 9473e65deb Merge pull request #419 from dadosfera/fix/reference-column-object-format
FIX: store reference_column as object with name and type
2025-12-17 13:48:38 -03:00
RafaelandClaude Opus 4.5 9c55c22230 FIX: store reference_column as object with name and type
The protobuf definition expects reference_column to be an object with
name and type fields, but it was being stored as just a string (column
name). This caused pipeline fetching to fail with the error:
".NewTable.reference_column: object expected"

Changes:
- Update ReferenceColumn interface in DynamoDB service
- Update extractTablesFromJobs to create reference_column object
- Update syncJobInputToDynamoDB to handle reference_column object

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 13:47:57 -03:00
Rafael Santana 77b9acd2d0 Merge pull request #418 from dadosfera/fix/reference-column-object-format
fix: store reference_column as object with name and type
2025-12-17 13:46:05 -03:00
RafaelandClaude Opus 4.5 e99306adba fix: store reference_column as object with name and type
The protobuf definition expects reference_column to be an object with
name and type fields, but it was being stored as just a string (column
name). This caused pipeline fetching to fail with the error:
".NewTable.reference_column: object expected"

Changes:
- Update ReferenceColumn interface in DynamoDB service
- Update extractTablesFromJobs to create reference_column object
- Update syncJobInputToDynamoDB to handle reference_column object

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 13:45:18 -03:00
Rafael Santana 96b947ebdc Merge pull request #417 from dadosfera/fix/redis-tls-configurable
FIX: pass type field to Elasticsearch createPipeline
2025-12-16 21:10:19 -03:00
RafaelandClaude Opus 4.5 fb521f53cd FIX: pass type field to Elasticsearch createPipeline
The type field was missing from the createPipeline call,
causing ES documents to not have the type field set.

Maps: jdbc->database, singer->application, s3->file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:09:07 -03:00
Rafael Santana 6f7436f33f Merge pull request #416 from dadosfera/fix/redis-tls-configurable
fix: pass type field to Elasticsearch createPipeline
2025-12-16 21:05:55 -03:00
RafaelandClaude Opus 4.5 865140e681 fix: pass type field to Elasticsearch createPipeline
The type field was missing from the createPipeline call,
causing ES documents to not have the type field set.

Maps: jdbc->database, singer->application, s3->file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:05:21 -03:00
Rafael Santana c4a664572a Merge pull request #415 from dadosfera/fix/redis-tls-configurable
UPDATE: force deployment
2025-12-16 19:17:08 -03:00
Rafael acb631e33d UPDATE: force deployment 2025-12-16 19:16:35 -03:00
Rafael Santana 7a10f88113 Merge pull request #414 from dadosfera/fix/redis-tls-configurable
fix: normalize IDs in pipeline run routes before calling Platform API
2025-12-16 19:11:09 -03:00
RafaelandClaude Opus 4.5 e616061c21 fix: normalize IDs in pipeline run routes before calling Platform API
- Add normalization for pipelineId and runId in getPipelineRuns, getPipelineRun, and getPipelineRunLogs
- Remove unused createPipelineRun route

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:10:23 -03:00
Rafael Santana 466f8fb8cc Merge pull request #413 from dadosfera/fix/redis-tls-configurable
UPDATE: force deployment
2025-12-16 18:23:44 -03:00
Rafael e03b9e7a14 UPDATE: force deployment 2025-12-16 18:23:19 -03:00
Rafael Santana 5989822263 Merge pull request #412 from dadosfera/fix/redis-tls-configurable
feat: add Elasticsearch access to Maestro deployment
2025-12-16 18:15:08 -03:00
RafaelandClaude Opus 4.5 2cc8f46418 feat: add Elasticsearch access to Maestro deployment
- Add ELASTICSEARCH_URL and ELASTICSEARCH_API_KEY env vars
- Pull credentials from {env}/microservices/elasticsearch secret

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 18:10:38 -03:00
vinicius gadea 64a3e2652e Merge pull request #410 from dadosfera/feature/catalog-update
Feature/catalog update
2025-12-16 18:01:47 -03:00
Rafael Moraes ea44a1cbb6 FEAT: Manual/Auto filter and UX Adjusments 2025-12-16 20:52:28 +00:00
Rafael Moraes fc9c0b0991 WIP: Filtro Source - backend 2025-12-16 20:13:20 +00:00
Rafael Moraes a4b5a44e44 Merge branch 'feature/catalog-update' of github.com:dadosfera/maestro into feature/catalog-update 2025-12-16 20:01:19 +00:00
Rafael Moraes d99a6aa322 Merge branch 'beta' of github.com:dadosfera/maestro into feature/catalog-update 2025-12-16 19:58:48 +00:00
Rafael Santana 3f910f851a Merge pull request #411 from dadosfera/fix/redis-tls-configurable
Fix/redis tls configurable
2025-12-16 16:58:41 -03:00
Rafael Moraes bd231382eb WIP: adiciona parâmetro asset_type no docsfera.json 2025-12-16 19:58:38 +00:00
Rafael 295f1f86ca Merge branch 'fix/redis-tls-configurable' of github.com:dadosfera/maestro into fix/redis-tls-configurable 2025-12-16 16:57:59 -03:00
Rafael 0a5e8001f9 UPDATE: adding platform_api_url 2025-12-16 16:57:25 -03:00
Rafael Santana 9c1979e17a Merge pull request #405 from dadosfera/fix/redis-tls-configurable
Fix/redis tls configurable
2025-12-16 16:41:04 -03:00
Rafael Santana b2700d4bb0 Merge branch 'beta' into fix/redis-tls-configurable 2025-12-16 16:39:11 -03:00
RafaelandClaude Opus 4.5 01c1087e07 FEAT: add schedule limit validation and improve ES update
- Add schedule limit validation against customer's scheduleLimit from DUC
- Improve ES update to only update provided fields
- Mark GET /platform/pipeline/:pipelineId/pipeline_run as READY

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 16:37:33 -03:00
viniciusgadea fcf7fb054e FIX: Update searchDataAssets to handle owner filtering and convert owner names to IDs 2025-12-16 09:55:24 -03:00
viniciusgadea 288aaeabc0 FIX: Enhance filter handling in BuildDataAssetsFilterTerms to support empty owner condition 2025-12-15 12:00:50 -03:00
viniciusgadea 918c3d7416 FIX: Enhance searchDataAssets to convert owner names to IDs and log the conversion 2025-12-15 09:08:08 -03:00
vinicius gadea 7dedb3bd33 Merge pull request #409 from dadosfera/feature/catalog-update
FIX: Simplify searchDataAssets by owner logging and remove log code
2025-12-12 15:51:17 -03:00
viniciusgadea 1ff5589a2e FIX: Simplify searchDataAssets by owner logging and remove log code 2025-12-12 15:03:43 -03:00
Rafael Moraes d24e9a1d80 Merge pull request #408 from dadosfera/feature/catalog-update
FIX: Calendar Fix
2025-12-11 22:26:25 +00:00
Rafael Moraes 7d3ef1ef92 FIX: Conflit resolution 2025-12-11 22:25:42 +00:00
Rafael Moraes df3f2489f9 FIX: Calendar Fix 2025-12-11 22:10:32 +00:00
Rafael Moraes 53245b0067 Merge pull request #407 from dadosfera/feature/catalog-update
FIX: correção no validate k8s
2025-12-11 16:50:11 +00:00
Rafael Moraes dd699614ae FIX: Maestro helm fix 2025-12-11 16:47:28 +00:00
Rafael Moraes 6919a2a8d0 FIX: helmfile diff plugin 2025-12-11 14:14:02 +00:00
Rafael Moraes 5c29e07450 FIX: helmfile 2025-12-11 14:07:04 +00:00
Rafael Moraes 00304262ba FIX: wget change in helmfile 2025-12-11 13:52:58 +00:00
vinicius gadea bc931c6dd8 Merge pull request #406 from dadosfera/feature/catalog-update
catalogo: alteracao nas pastas e acrescimo de filtros
2025-12-11 09:18:57 -03:00
Rafael Moraes c444d6e956 Merge pull request #404 from dadosfera/feat/add-docs-assets
Feat/add docs assets
2025-12-10 19:42:42 +00:00
RafaelandClaude Opus 4.5 f7efb757bf FEAT: add schedule limit validation and improve ES update
- Add cron-validation utility with Airflow presets support (@once, @daily, etc.)
- Validate cron expressions against customer scheduleLimit from DUC
- Fetch scheduleLimit via gRPC to ensure up-to-date configuration
- Improve ES updatePipeline to fetch current doc and merge changes
- Only update fields explicitly provided in PATCH request body
- Preserve config.tables when updating config.cron

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:56:49 -03:00
Rafael Moraes 2f140d213a catalogo: alteracao nas pastas e acrescimo de filtros 2025-12-10 16:51:28 +00:00
RafaelandClaude Opus 4.5 df674dd441 FEAT: add Platform API proxy with Elasticsearch and DynamoDB sync
- Add Platform API module to proxy requests to Platform API service
- Add Elasticsearch service for pipeline catalog sync (CRUD operations)
- Add DynamoDB service for storing pipeline tables/inputs configuration
- Sync pipeline creation/update/delete to Elasticsearch index
- Extract tables from jobs with connector-specific mappings:
  - JDBC: table_name, load_type, column_include_list, incremental_column_name
  - Singer: replication_method -> full_load/incremental
  - S3: same as Singer
- Map connector types to DynamoDB types (jdbc->database, singer->application, s3->file)
- Validate connector type is provided in job input
- Normalize pipeline IDs for Platform API (replace - with _), keep UUIDs for ES

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:42:18 -03:00
viniciusgadea 09dced9fcd REFACTOR: add asset_type query parameter to getDataDocs and separate data docs methods 2025-12-10 11:21:46 -03:00
viniciusgadea 165b533172 FIX: removing unnecessary type parameter from GetDatasetDoc call 2025-12-10 11:17:43 -03:00
viniciusgadea b1dc567394 DOCS: update @dadosfera/protospack-v2 to version 3.38.0-beta.16 2025-12-10 11:12:13 -03:00
viniciusgadea a0a1303515 DOCS: add automatic asset_type query parameter to API schema and packages dependencies 2025-12-09 14:56:51 -03:00
viniciusgadea c21d977f7c FEAT: enhance catalog service to support any asset type in data docs creation 2025-12-09 14:56:51 -03:00
viniciusgadea 399d3492d3 DOCS: update @dadosfera/protospack-v2 to version 3.38.0-beta.15 2025-12-09 14:56:51 -03:00
Marcos Rodrigues Silva b0b557246e Merge pull request #403 from dadosfera/feat/user-properties
FIX:  change origin to host
2025-12-03 15:26:10 -03:00
Marcos Rodrigues bb671a90d6 FIX: change origin to host 2025-12-03 15:20:41 -03:00
Marcos Rodrigues Silva f6ababbe7a Merge pull request #402 from dadosfera/feat/user-properties
FIX: using header host
2025-12-03 14:46:14 -03:00
Marcos Rodrigues 82290285d0 FIX: using header host 2025-12-03 14:41:51 -03:00
Marcos Rodrigues Silva c490814a98 Merge pull request #401 from dadosfera/feat/user-properties
FEAT: add resource origin validate in me endpoint
2025-12-03 13:53:33 -03:00
Marcos Rodrigues 7616b1e32c FEAT: add resource origin validate in me endpoint 2025-12-03 13:51:32 -03:00
Rafael Santana 3ba2c91893 Merge pull request #400 from dadosfera/fix/redis-tls-configurable
UPDATE: force deployment
2025-12-02 15:30:03 -03:00
Rafael e789076ed4 UPDATE: force deployment 2025-12-02 15:29:36 -03:00
Rafael Santana 3b6ddaaee6 Merge pull request #399 from dadosfera/fix/redis-tls-configurable
fix: make Redis TLS configurable via REDIS_TLS env var
2025-12-02 15:18:05 -03:00
RafaelandClaude 8073194604 fix: make Redis TLS configurable via REDIS_TLS env var
- Change TLS check from ENV !== 'local' to REDIS_TLS === 'true'
- Add REDIS_TLS env var to Helm deployment template
- Default to true for OCI Redis, can override for self-hosted K8s

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 15:10:31 -03:00
59 changed files with 8903 additions and 3908 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
*.log
npm-debug.log*
.DS_Store
.env
.env.*
coverage
.nyc_output
*.tgz
!protospack.tgz
+6 -3
View File
@@ -66,13 +66,16 @@ jobs:
- name: Install Helmfile
run: |
wget https://github.com/helmfile/helmfile/releases/download/v0.148.0/helmfile_0.148.0_linux_amd64.tar.gz
curl -fsSLO 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 || true
- name: Debug Helm env
run: |
helm env
echo "HOME=$HOME"
ls -R $HOME/.local/share/helm || true
- name: Authenticate with OKE cluster
env:
+2 -1
View File
@@ -1,4 +1,5 @@
FROM node:18.17-alpine AS base_image
FROM node:20-alpine AS base_image
RUN npm install -g npm@latest
FROM base_image AS build_base
WORKDIR /app
+47
View File
@@ -0,0 +1,47 @@
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
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"]
-482
View File
@@ -1,482 +0,0 @@
# Maestro ↔ In-Factory Migration Map
This document maps all integration points between Maestro and In-Factory that need to be addressed to remove the dependency.
## Architecture Overview
```mermaid
graph TD;
Frontend<-->Maestro;
Maestro<-->DUC;
Maestro<-->pi-factory;
Maestro<-->in-factory;
```
Maestro connects to **3 external microservices**:
- **DUC** (`DUC_URL`) - User management, authentication, permissions
- **PI-Factory** (`PIFACTORY_URL`) - Pipelines, Catalog services
- **IN-Factory** (`INFACTORY_URL`) - Connections, Connectors, Inputs, Network Config, Transformations
---
## Summary Table
| Category | Count | Impact Level |
|----------|-------|--------------|
| gRPC Client Configurations | 6 | HIGH |
| NestJS Modules | 6 | HIGH |
| REST Controllers/Endpoints | 6 | HIGH |
| Service Classes | 6 | HIGH |
| Configuration Files | 5 | MEDIUM |
| Proto Package Dependencies | 2 | HIGH |
| Environment Variables | 2 | LOW |
---
## 1. gRPC CLIENT CONFIGURATIONS (Files to Migrate)
These files configure gRPC connections to In-Factory services:
### 1.1 Connection Client (`INFACTORY_URL`)
**File:** `src/modules/connection/client.config.ts`
```typescript
// Lines 10-11, 18
process.env.INFACTORY_URL.startsWith('in-factory:')
process.env.INFACTORY_URL.includes('0.0.0.0')
url: process.env.INFACTORY_URL
```
**Proto Services Used:**
- `ConnectionManager.ProtoPackages.WritePackage`
- `ConnectionManager.ProtoPackages.ReadPackage`
### 1.2 Connector Client (`INFACTORY_URL`)
**File:** `src/modules/connector/client.config.ts`
```typescript
// Lines 10-11, 18
url: process.env.INFACTORY_URL
```
**Proto Services Used:**
- `ConnectorManager.ProtoPackages.WritePackage`
- `ConnectorManager.ProtoPackages.ReadPackage`
### 1.3 Inputs Client (`INFACTORY_URL`)
**File:** `src/modules/inputs/inputs-client.config.ts`
```typescript
// Lines 10-11, 17
url: process.env.INFACTORY_URL
```
**Proto Services Used:**
- `Input.ProtoPackages.WritePackage`
- `Input.ProtoPackages.ReadPackage`
### 1.4 Network Config Client (`INFACTORY_URL`)
**File:** `src/modules/network-config/network-config-client.config.ts`
```typescript
// Lines 10-11, 19
url: process.env.INFACTORY_URL
```
**Proto Services Used:**
- `NetworkConfig.ProtoPackages.WritePackage`
- `NetworkConfig.ProtoPackages.ReadPackage`
### 1.5 Connection Test Client (`INFACTORY_URL`)
**File:** `src/modules/connection-test/connection-test-client.config.ts`
```typescript
// Lines 10-11, 17
url: process.env.INFACTORY_URL
```
**Proto Services Used:**
- `ConnectionTest.ProtoPackages.ReadPackage`
### 1.6 Transformations Client (`INFACTORY_URL`)
**File:** `src/modules/transformations/transformations-client.ts`
```typescript
// Lines 10-11, 18
url: process.env.INFACTORY_URL
```
**Proto Services Used:**
- `Transformation.ProtoPackages.WritePackage`
---
## 2. SERVICE CLASSES (Business Logic to Migrate)
### 2.1 Connection Client Service
**File:** `src/modules/connection/client.service.ts`
**gRPC Methods Called:**
| Method | Service | Direction |
|--------|---------|-----------|
| `CreateConnection()` | ConnectionManagerWriteServices | Write |
| `UpdateConnection()` | ConnectionManagerWriteServices | Write |
| `DeleteConnection()` | ConnectionManagerWriteServices | Write |
| `GetConnectionDetails()` | ConnectionManagerReadServices | Read |
| `GetAllConnections()` | ConnectionManagerReadServices | Read |
| `GetConnectorAvailableConnectionsByCustomer()` | ConnectionManagerReadServices | Read |
| `ValidatePlatformConnections()` | ConnectionManagerReadServices | Read |
**Key Methods:**
- `createConnection()`
- `updateConnection()`
- `deleteConnection()`
- `getConnections()`
- `getConnectionDetails()`
- `validatePlatformConnections()`
### 2.2 Connector Client Service
**File:** `src/modules/connector/client.service.ts`
**gRPC Methods Called:**
| Method | Service | Direction |
|--------|---------|-----------|
| `RegisterConnector()` | ConnectorManagerWriteServices | Write |
| `UploadFile()` | ConnectorManagerWriteServices | Write |
| `RegisterMultipleConnectorsWithoutImage()` | ConnectorManagerWriteServices | Write |
| `UpdateConnectorByID()` | ConnectorManagerWriteServices | Write |
| `DeleteConnectorById()` | ConnectorManagerWriteServices | Write |
| `GetConnectors()` | ConnectorManagerReadServices | Read |
### 2.3 Inputs Service
**File:** `src/modules/inputs/inputs.service.ts`
**gRPC Methods Called:**
| Method | Service | Direction |
|--------|---------|-----------|
| `CreateInput()` | InputWriteService | Write |
| `UpdateInput()` | InputWriteService | Write |
| `DeleteInput()` | InputWriteService | Write |
| `GetAvailableEntities()` | InputReadService | Read |
### 2.4 Network Config Service
**File:** `src/modules/network-config/network-config.service.ts`
**gRPC Methods Called:**
| Method | Service | Direction |
|--------|---------|-----------|
| `NetworkConfigCreate()` | NetworkConfigWriteService | Write |
| `NetworkConfigUpdate()` | NetworkConfigWriteService | Write |
| `NetworkConfigDelete()` | NetworkConfigWriteService | Write |
| `NetworkConfigFindAll()` | NetworkConfigReadService | Read |
| `NetworkConfigFindOneById()` | NetworkConfigReadService | Read |
### 2.5 Connection Test Service
**File:** `src/modules/connection-test/connection-test.service.ts`
**gRPC Methods Called:**
| Method | Service | Direction |
|--------|---------|-----------|
| `TestConnection()` | ConnectionTestReadService | Read |
### 2.6 Transformations Service
**File:** `src/modules/transformations/client.service.ts`
**gRPC Methods Called:**
| Method | Service | Direction |
|--------|---------|-----------|
| `CreateTransformation()` | TransformationWriteService | Write |
| `UpdateTransformation()` | TransformationWriteService | Write |
| `DeleteTransformation()` | TransformationWriteService | Write |
| Various read operations | TransformationReadService | Read |
---
## 3. NESTJS MODULES (Module Registration)
These modules register the gRPC clients and export services:
| Module | File | Imports | Exports |
|--------|------|---------|---------|
| ConnectionModule | `src/modules/connection/connection.module.ts` | ConnectionClientConfiguration | ConnectionClientService |
| ConnectorModule | `src/modules/connector/connector.module.ts` | ConnectorClientConfiguration | ConnectorClientService |
| InputsModule | `src/modules/inputs/inputs.module.ts` | InputsGrpcClient | InputsService |
| NetworkConfigModule | `src/modules/network-config/network-config.module.ts` | NetworkConfigGrpcClient | NetworkConfigService |
| ConnectionTestModule | `src/modules/connection-test/connection-test.module.ts` | ConnectionTestClientConfiguration | ConnectionTestService |
| TransformationsModule | `src/modules/transformations/transformations.module.ts` | TransformationsClientConfiguration | TransformationsService |
**App Module Registration:** `src/app.module.ts` (Lines 15-21, 50-60)
---
## 4. REST CONTROLLERS (API Endpoints to Migrate)
### 4.1 Connection Controller
**File:** `src/modules/connection/connection.controller.ts`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| POST | `/connections` | Create a new connection |
| PUT | `/connections/:id` | Update an existing connection |
| DELETE | `/connections/:id` | Delete a connection |
| GET | `/connections` | Get all connections |
| GET | `/connections/:id` | Get connection details |
| GET | `/connections/available/:connector_id` | Get available connections by connector |
### 4.2 Connector Controller
**File:** `src/modules/connector/connector.controller.ts`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| POST | `/connectors` | Register a new connector |
| POST | `/connectors/uploads` | Upload multiple connector files |
| GET | `/connectors` | List all connectors |
| PUT | `/connectors/:id` | Update a connector |
| DELETE | `/connectors/:id` | Delete a connector |
### 4.3 Inputs Controller
**File:** `src/modules/inputs/inputs.controller.ts`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| GET | `/inputs/available-entities/:plugin` | Get available entities for a plugin |
| POST | `/inputs` | Create an input |
| PATCH | `/inputs` | Update an input |
| DELETE | `/inputs/:id` | Delete an input |
### 4.4 Network Config Controller
**File:** `src/modules/network-config/network-config.controller.ts`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| GET | `/network-configs` | Get all network configurations |
| GET | `/network-configs/:id` | Get network config by ID |
| POST | `/network-configs` | Create network configuration |
| PUT | `/network-configs/:id` | Update network configuration |
| DELETE | `/network-configs/:id` | Delete network configuration |
### 4.5 Connection Test Controller
**File:** `src/modules/connection-test/connection-test.controller.ts`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| POST | `/connection-test` | Test a connection |
### 4.6 Transformations Controller
**File:** `src/modules/transformations/transformations.controller.ts`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| POST | `/transformations` | Create transformation |
| GET | `/transformations` | Get transformations |
| PUT | `/transformations/:id` | Update transformation |
| DELETE | `/transformations/:id` | Delete transformation |
---
## 5. CONFIGURATION FILES
### 5.1 Helm Chart Values (Production)
**File:** `deploy/helm-chart/values.yaml`
```yaml
# Lines 32, 42
maestro:
in_factory_url: in-factory.dadosfera.ai
tr_factory_url: in-factory.dadosfera.ai # Transformation factory also uses in-factory
```
### 5.2 Helm Chart Values (Staging)
**File:** `deploy/helm-chart/values-stg.yaml`
```yaml
# Lines 5-6
maestro:
in_factory_url: in-factory.stg.dadosfera.ai
tr_factory_url: in-factory.stg.dadosfera.ai
```
### 5.3 Helmfiles (Production)
**File:** `deploy/helmfiles/prd.yaml`
```yaml
# Lines 15-18, 39-42 (for both maestro and maestro-unimed releases)
maestro.in_factory_url: in-factory.dadosfera.ai
maestro.tr_factory_url: in-factory.dadosfera.ai
```
### 5.4 Deployment Template
**File:** `deploy/helm-chart/templates/deployment.yaml`
```yaml
# Lines 71-72
- name: INFACTORY_URL
value: {{ .Values.maestro.in_factory_url }}
```
### 5.5 Environment Type Definition
**File:** `environment.d.ts`
```typescript
// Line 9
INFACTORY_URL: string;
```
---
## 6. PROTO PACKAGE DEPENDENCIES
### 6.1 Package.json
**File:** `package.json`
```json
{
"@dadosfera/protospack": "2.5.3",
"@dadosfera/protospack-v2": "3.38.0-beta.14"
}
```
### 6.2 Proto Imports from `@dadosfera/protospack-v2` (In-Factory related)
| Import Path | Used In |
|-------------|---------|
| `ConnectionManager` | connection/client.config.ts, connection/client.service.ts |
| `ConnectionManager/interfaces/messages` | connection/client.service.ts, connection/dtos/connection.ts |
| `ConnectionManager/interfaces/entities` | connection/dtos/connection.ts |
| `ConnectorManager` | connector/client.config.ts, connector/client.service.ts |
| `Input` | inputs/inputs-client.config.ts, inputs/inputs.service.ts |
| `Input/interfaces/messages` | inputs/inputs.service.ts |
| `Input/interfaces/entities` | inputs/inputs.controller.ts, inputs/inputs.service.ts |
| `NetworkConfig` | network-config/network-config-client.config.ts, network-config/network-config.service.ts |
| `NetworkConfig/interfaces/entities` | network-config/dto/network-config.ts |
| `ConnectionTest` | connection-test/connection-test-client.config.ts, connection-test/connection-test.service.ts |
| `Transformation` | transformations/transformations-client.ts, transformations/client.service.ts |
---
## 7. MIGRATION STRATEGY OPTIONS
### Option A: Move In-Factory functionality INTO Maestro
**Pros:**
- Single service to maintain
- No network latency for these operations
- Simpler deployment
**Cons:**
- Increases Maestro's responsibility/complexity
- Requires database access from Maestro
- May require significant refactoring
**Files to create/migrate:**
1. Database models for connections, connectors, inputs, network-configs, transformations
2. Repository layer for database operations
3. Convert gRPC services to internal services
4. Remove all gRPC client configurations
### Option B: Create REST API wrapper in In-Factory
**Pros:**
- Minimal changes to Maestro
- Can migrate incrementally
**Cons:**
- Still maintains dependency
- Additional REST→gRPC translation layer
### Option C: Direct database access from Maestro
**Pros:**
- Removes runtime dependency
- Better performance
**Cons:**
- Shared database coupling
- Complex migration
---
## 8. FILES TO MODIFY/DELETE (Summary)
### High Priority - Core Integration Files
```
src/modules/connection/client.config.ts → DELETE or REPLACE
src/modules/connection/client.service.ts → REPLACE with local implementation
src/modules/connector/client.config.ts → DELETE or REPLACE
src/modules/connector/client.service.ts → REPLACE with local implementation
src/modules/inputs/inputs-client.config.ts → DELETE or REPLACE
src/modules/inputs/inputs.service.ts → REPLACE with local implementation
src/modules/network-config/network-config-client.config.ts → DELETE or REPLACE
src/modules/network-config/network-config.service.ts → REPLACE with local implementation
src/modules/connection-test/connection-test-client.config.ts → DELETE or REPLACE
src/modules/connection-test/connection-test.service.ts → REPLACE with local implementation
src/modules/transformations/transformations-client.ts → DELETE or REPLACE
src/modules/transformations/client.service.ts → REPLACE with local implementation
```
### Medium Priority - Module Registration
```
src/modules/connection/connection.module.ts → UPDATE imports
src/modules/connector/connector.module.ts → UPDATE imports
src/modules/inputs/inputs.module.ts → UPDATE imports
src/modules/network-config/network-config.module.ts → UPDATE imports
src/modules/connection-test/connection-test.module.ts → UPDATE imports
src/modules/transformations/transformations.module.ts → UPDATE imports
src/app.module.ts → UPDATE if module structure changes
```
### Low Priority - Configuration
```
deploy/helm-chart/values.yaml → REMOVE in_factory_url, tr_factory_url
deploy/helm-chart/values-stg.yaml → REMOVE in_factory_url, tr_factory_url
deploy/helmfiles/prd.yaml → REMOVE in_factory_url references
deploy/helm-chart/templates/deployment.yaml → REMOVE INFACTORY_URL env var
environment.d.ts → REMOVE INFACTORY_URL type
README.md → UPDATE architecture diagram
```
---
## 9. DEPENDENCY COUNT BY MODULE
| Module | Files | gRPC Calls | REST Endpoints |
|--------|-------|------------|----------------|
| Connection | 4 | 7 | 6 |
| Connector | 3 | 6 | 5 |
| Inputs | 3 | 4 | 4 |
| Network Config | 3 | 5 | 5 |
| Connection Test | 3 | 1 | 1 |
| Transformations | 4 | 4+ | 4 |
| **TOTAL** | **20** | **27+** | **25** |
---
## 10. DATA MODELS (Proto Messages Used)
### Connection Manager
- `CreateConnectionRequest` / `CreateConnectionResponse`
- `UpdateConnectionRequest` / `UpdateConnectionResponse`
- `DeleteConnectionRequest` / `DeleteConnectionResponse`
- `GetConnectionDetailsRequest` / `GetConnectionDetailsResponse`
- `GetAllConnectionsRequest` / `GetAllConnectionsResponse`
- `Connection` (entity)
- `ConnectionCredential` (entity)
### Connector Manager
- `RegisterConnectorRequest` / `RegisterConnectorResponse`
- `UploadFileRequest` / `UploadFileResponse`
- `GetConnectorsRequest` / `GetConnectorsResponse`
- `Connector` (entity)
### Input
- `CreateInputRequest` / `CreateInputResponse`
- `UpdateInputRequest` / `UpdateInputResponse`
- `DeleteInputRequest` / `DeleteInputResponse`
- `GetAvailableEntitiesRequest` / `GetAvailableEntitiesResponse`
- `Info` (entity)
### Network Config
- `NetworkConfigCreateRequest` / `NetworkConfigCreateResponse`
- `NetworkConfigUpdateRequest` / `NetworkConfigUpdateResponse`
- `NetworkConfigDeleteRequest` / `NetworkConfigDeleteResponse`
- `NetworkConfigFindAllRequest` / `NetworkConfigFindAllResponse`
- `NetworkConfig` (entity)
### Connection Test
- `TestConnectionRequest` / `TestConnectionResponse`
### Transformation
- `CreateTransformationRequest` / `CreateTransformationResponse`
- `UpdateTransformationRequest` / `UpdateTransformationResponse`
- `DeleteTransformationRequest` / `DeleteTransformationResponse`
---
## NEXT STEPS
1. **Decide on migration strategy** (Option A, B, or C)
2. **Prioritize modules** - Recommend starting with Connection Test (smallest), then Inputs, Network Config, Transformations, Connection, Connector (largest)
3. **Create database schema** if moving to Option A
4. **Implement local services** one module at a time
5. **Update tests** for each migrated module
6. **Update deployment configs** to remove INFACTORY_URL
7. **Coordinate with In-Factory team** for data migration
-320
View File
@@ -1,320 +0,0 @@
# In-Factory Side - Migration Requirements
This document outlines what needs to be addressed in the **In-Factory** service to remove its coupling with Maestro.
> **Note:** This analysis is based on the Maestro codebase. For a complete analysis, the In-Factory repository should also be reviewed.
---
## Current Architecture (In-Factory → Maestro)
Based on the Maestro codebase analysis, **In-Factory** exposes the following gRPC services that Maestro consumes:
```mermaid
graph LR;
Maestro -->|gRPC| InFactory;
subgraph InFactory Services
CM[ConnectionManager]
ConM[ConnectorManager]
IN[Input]
NC[NetworkConfig]
CT[ConnectionTest]
TR[Transformation]
end
Maestro --> CM;
Maestro --> ConM;
Maestro --> IN;
Maestro --> NC;
Maestro --> CT;
Maestro --> TR;
```
---
## gRPC Services Exposed by In-Factory
### 1. ConnectionManager Service
**Package:** `ConnectionManager` from `@dadosfera/protospack-v2`
#### Write Services (`ConnectionManagerWriteServices`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `CreateConnection` | Create a new data connection | Maestro POST /connections |
| `UpdateConnection` | Update an existing connection | Maestro PUT /connections/:id |
| `DeleteConnection` | Delete a connection | Maestro DELETE /connections/:id |
#### Read Services (`ConnectionManagerReadServices`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `GetConnectionDetails` | Get details of a single connection | Maestro GET /connections/:id |
| `GetAllConnections` | List all connections for a customer | Maestro GET /connections |
| `GetConnectorAvailableConnectionsByCustomer` | Get available connections by connector | Maestro GET /connections/available/:connector_id |
| `ValidatePlatformConnections` | Validate connections against platform | Maestro internal |
---
### 2. ConnectorManager Service
**Package:** `ConnectorManager` from `@dadosfera/protospack-v2`
#### Write Services (`ConnectorManagerWriteServices`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `RegisterConnector` | Register a new connector | Maestro POST /connectors |
| `UploadFile` | Upload connector files | Maestro POST /connectors/uploads |
| `RegisterMultipleConnectorsWithoutImage` | Bulk register connectors | Maestro internal |
| `UpdateConnectorByID` | Update a connector | Maestro PUT /connectors/:id |
| `DeleteConnectorById` | Delete a connector | Maestro DELETE /connectors/:id |
#### Read Services (`ConnectorManagerReadServices`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `GetConnectors` | List all connectors | Maestro GET /connectors |
---
### 3. Input Service
**Package:** `Input` from `@dadosfera/protospack-v2`
#### Write Services (`InputWriteService`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `CreateInput` | Create a new input configuration | Maestro POST /inputs |
| `UpdateInput` | Update input configuration | Maestro PATCH /inputs |
| `DeleteInput` | Delete an input | Maestro DELETE /inputs/:id |
#### Read Services (`InputReadService`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `GetAvailableEntities` | Get available entities for a plugin | Maestro GET /inputs/available-entities/:plugin |
---
### 4. NetworkConfig Service
**Package:** `NetworkConfig` from `@dadosfera/protospack-v2`
#### Write Services (`NetworkConfigWriteService`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `NetworkConfigCreate` | Create network configuration | Maestro POST /network-configs |
| `NetworkConfigUpdate` | Update network configuration | Maestro PUT /network-configs/:id |
| `NetworkConfigDelete` | Delete network configuration | Maestro DELETE /network-configs/:id |
#### Read Services (`NetworkConfigReadService`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `NetworkConfigFindAll` | List all network configs | Maestro GET /network-configs |
| `NetworkConfigFindOneById` | Get network config by ID | Maestro GET /network-configs/:id |
---
### 5. ConnectionTest Service
**Package:** `ConnectionTest` from `@dadosfera/protospack-v2`
#### Read Services (`ConnectionTestReadService`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `TestConnection` | Test a connection's connectivity | Maestro POST /connection-test |
---
### 6. Transformation Service
**Package:** `Transformation` from `@dadosfera/protospack-v2`
#### Write Services (`TransformationWriteService`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| `CreateTransformation` | Create a transformation | Maestro POST /transformations |
| `UpdateTransformation` | Update a transformation | Maestro PUT /transformations/:id |
| `DeleteTransformation` | Delete a transformation | Maestro DELETE /transformations/:id |
#### Read Services (`TransformationReadService`)
| gRPC Method | Description | Called By |
|-------------|-------------|-----------|
| Various read operations | Get transformation details | Maestro GET /transformations |
---
## Proto Package Ownership
The proto definitions are managed in `@dadosfera/protospack-v2`:
```
@dadosfera/protospack-v2/
├── dist/lib/
│ ├── ConnectionManager/
│ │ ├── interfaces/messages.ts
│ │ └── interfaces/entities.ts
│ ├── ConnectorManager/
│ │ ├── interfaces/messages.ts
│ │ └── interfaces/entities.ts
│ ├── Input/
│ │ ├── interfaces/messages.ts
│ │ └── interfaces/entities.ts
│ ├── NetworkConfig/
│ │ ├── interfaces/messages.ts
│ │ └── interfaces/entities.ts
│ ├── ConnectionTest/
│ │ ├── interfaces/messages.ts
│ │ └── interfaces/entities.ts
│ └── Transformation/
│ ├── interfaces/messages.ts
│ └── interfaces/entities.ts
```
---
## In-Factory Migration Options
### Option 1: Expose REST API (Keep In-Factory, Add REST Layer)
**Changes Required in In-Factory:**
1. Add REST controllers for all services
2. Implement HTTP endpoints mirroring gRPC methods
3. Update deployment to expose HTTP port
4. Create OpenAPI documentation
**Pros:**
- Minimal architectural changes
- Can run both gRPC and REST in parallel during migration
- Maestro can switch to REST calls gradually
**Cons:**
- Still maintains service dependency
- Adds another communication layer
---
### Option 2: Move Logic to Maestro (Deprecate In-Factory for these features)
**Changes Required in In-Factory:**
1. Export database schema/migrations
2. Document all business logic
3. Provide data migration scripts
4. Deprecate gRPC endpoints after migration
**Changes Required in Maestro:**
1. Create database models
2. Implement repositories
3. Create service layer with same business logic
4. Run data migration
**Pros:**
- Removes runtime dependency completely
- Simplifies architecture
- One less service to maintain
**Cons:**
- Significant development effort
- Risk of business logic divergence during migration
- Database sharing concerns
---
### Option 3: Merge Services (Combine In-Factory into a larger service)
**Changes Required:**
1. Create new combined service
2. Migrate both In-Factory and relevant Maestro code
3. Update all clients
**Pros:**
- Clean architectural redesign
- Opportunity to optimize
**Cons:**
- Largest effort
- Risk of disruption
---
## Data Migration Considerations
### Entities Managed by In-Factory
Based on proto definitions, In-Factory manages:
1. **Connections**
- Connection credentials
- Connection metadata
- Customer associations
2. **Connectors**
- Connector definitions
- Connector images/files
- Plugin configurations
3. **Inputs**
- Input configurations
- Entity mappings
4. **Network Configs**
- Network configuration settings
- Security settings
5. **Transformations**
- Transformation definitions
- Transformation scripts
### Migration Steps
1. Export database schema from In-Factory
2. Create equivalent schema in target database
3. Write data migration scripts
4. Validate data integrity
5. Switch traffic
6. Decommission old service
---
## Recommended Investigation for In-Factory Team
1. **Check for Maestro dependencies in In-Factory**
- Does In-Factory call any Maestro APIs?
- Are there any shared databases?
- Any shared message queues?
2. **Document database schema**
- All tables related to connections, connectors, inputs, network-configs, transformations
- Foreign key relationships
- Indexes and constraints
3. **List all consumers**
- Besides Maestro, who else calls In-Factory?
- Are there other internal services?
- Any external integrations?
4. **Business logic documentation**
- Validation rules
- Business constraints
- Side effects (events, notifications, etc.)
---
## Timeline Considerations
| Phase | Description | Dependencies |
|-------|-------------|--------------|
| Phase 1 | Analysis & Planning | Both teams available |
| Phase 2 | Schema/API Design | Proto definitions finalized |
| Phase 3 | Implementation | Development resources |
| Phase 4 | Data Migration | Database access, downtime window |
| Phase 5 | Testing | QA resources, test environments |
| Phase 6 | Cutover | Deployment coordination |
| Phase 7 | Decommission | Monitoring, rollback plan |
---
## Questions for In-Factory Team
1. What database does In-Factory use? (PostgreSQL, MongoDB, etc.)
2. Are there any async operations? (message queues, event sourcing)
3. What is the current data volume for each entity type?
4. Are there any scheduled jobs or background processes?
5. What monitoring/alerting is in place?
6. Are there any data retention policies?
7. What is the backup/recovery strategy?
-486
View File
@@ -1,486 +0,0 @@
# Maestro ↔ PI-Factory Migration Map
This document maps all integration points between Maestro and PI-Factory that need to be addressed to remove the dependency.
## Architecture Overview
```mermaid
graph TD;
Frontend<-->Maestro;
Maestro<-->DUC;
Maestro<-->pi-factory;
Maestro<-->in-factory;
```
PI-Factory (`PIFACTORY_URL`) is responsible for:
- **Pipeline Management** - Create, read, update, delete pipelines
- **Catalog Services** - Data asset management, metadata, previews
- **Platform Interfaces** - Dataset cataloging operations
---
## Summary Table
| Category | Count | Impact Level |
|----------|-------|--------------|
| gRPC Client Configurations | 3 | HIGH |
| NestJS Modules | 3 | HIGH |
| REST Controllers/Endpoints | 3 (~50 endpoints) | HIGH |
| Service Classes | 4 | HIGH |
| Configuration Files | 4 | MEDIUM |
| Proto Package Dependencies | 2 | HIGH |
| Environment Variables | 1 | LOW |
---
## 1. gRPC CLIENT CONFIGURATIONS (Files to Migrate)
These files configure gRPC connections to PI-Factory services:
### 1.1 Catalog Client (`PIFACTORY_URL`)
**File:** `src/modules/catalog/catalog-client.ts`
```typescript
// Lines 11-12, 19
process.env.PIFACTORY_URL.startsWith('pi-factory:')
process.env.PIFACTORY_URL.includes('0.0.0.0')
url: process.env.PIFACTORY_URL
```
**Proto Services Used:**
- `Catalog.ProtoPackages.ReadPackage`
- `Catalog.ProtoPackages.WritePackage`
- `PlatformInterfaces.ProtoPackages.WritePackage`
### 1.2 Pipelines V2 Client (`PIFACTORY_URL`)
**File:** `src/modules/pipelinesV2/pipelines-client.ts`
```typescript
// Lines 13-14, 21
url: process.env.PIFACTORY_URL
```
**Proto Services Used:**
- `PipelineV2.ProtoPackages.ReadPackage`
- `PipelineV2.ProtoPackages.WritePackage`
### 1.3 Pipelines Client (Legacy) (`PIFACTORY_URL`)
**File:** `src/modules/pipelines/pipelines-client.ts`
```typescript
// Lines 10-11, 18
url: process.env.PIFACTORY_URL
```
**Proto Services Used:**
- `PipelinePackages` from `@dadosfera/protospack`
---
## 2. SERVICE CLASSES (Business Logic to Migrate)
### 2.1 Catalog Service
**File:** `src/modules/catalog/catalog.service.ts`
**gRPC Services Initialized:**
- `CatalogReadServices` (from `Catalog.ReadService`)
- `CatalogWriteServices` (from `Catalog.WriteService`)
- `PlatformInterfacesWriteServices` (from `PlatformInterfaces.WriteService`)
**gRPC Methods Called:**
| Method | Service | Description |
|--------|---------|-------------|
| `GetAllDataAssets()` | CatalogReadServices | Search/list data assets |
| `GetOneDataAsset()` | CatalogReadServices | Get single data asset by ID |
| `GetOneDataAssetByPipelineAndObject()` | CatalogReadServices | Get asset by pipeline/object |
| `GetDatasetDoc()` | CatalogReadServices | Get dataset documentation |
| `GetDatasetPreview()` | CatalogReadServices | Get data preview |
| `GetDatasetColumnsMetadata()` | CatalogReadServices | Get column metadata |
| `GetCustomerTags()` | CatalogReadServices | Get all tags for customer |
| `GetDatasetCatalogTask()` | CatalogReadServices | Get catalog task status |
| `GetRlsRules()` | CatalogReadServices | Get RLS rules |
| `GetOneRlsRule()` | CatalogReadServices | Get single RLS rule |
| `GetNimbusDashboards()` | CatalogReadServices | Get Nimbus dashboards |
| `CreateDataAsset()` | CatalogWriteServices | Create new data asset |
| `UpdateDataAsset()` | CatalogWriteServices | Update existing data asset |
| `DeleteDataAsset()` | CatalogWriteServices | Delete data asset |
| `ManagePermission()` | CatalogWriteServices | Manage asset permissions |
| `RevokePermission()` | CatalogWriteServices | Revoke asset permissions |
| `MakeAComment()` | CatalogWriteServices | Add comment to asset |
| `UpdateAComment()` | CatalogWriteServices | Update/delete comment |
| `TriggerDatasetCataloging()` | CatalogWriteServices | Trigger catalog process |
| `AddRlsRule()` | CatalogWriteServices | Add RLS rule |
| `RemoveRlsRule()` | CatalogWriteServices | Remove RLS rule |
| `RemoveRlsRulesByRlsId()` | CatalogWriteServices | Batch remove by RLS ID |
| `RemoveRlsRulesByDashboardId()` | CatalogWriteServices | Batch remove by dashboard |
| `GetPiiReporter()` | CatalogWriteServices | Get PII report data |
| `CatalogDataAssets()` | PlatformInterfacesWriteServices | Catalog datasets |
**Additional HTTP Calls to Nimbus:**
- `POST ${nimbusUrl}/api/catalog/data-docs/` - Create data docs
- `POST ${nimbusUrl}/api/catalog/table-metadata/` - Create table metadata
- `POST ${nimbusUrl}/api/catalog/column-metadata/` - Create column metadata
- `POST ${nimbusUrl}/api/catalog/data-preview/` - Create data preview
### 2.2 Pipelines V2 Service
**File:** `src/modules/pipelinesV2/pipelines.service.ts`
**gRPC Services Initialized:**
- `PipelineV2ReadService` (from `PipelineV2.ReadService`)
- `PipelineV2WriteService` (from `PipelineV2.WriteService`)
**gRPC Methods Called:**
| Method | Service | Description |
|--------|---------|-------------|
| `PipelineV2Create()` | PipelineV2WriteService | Create new pipeline |
| `PipelineV2Update()` | PipelineV2WriteService | Update pipeline |
| `PipelineV2Remove()` | PipelineV2WriteService | Delete pipeline |
| `PipelineV2UploadFile()` | PipelineV2WriteService | Initialize file upload |
| `PipelineV2CompleteUploadFile()` | PipelineV2WriteService | Complete file upload |
| `PipelineV2FindAll()` | PipelineV2ReadService | List all pipelines |
| `PipelineV2FindOne()` | PipelineV2ReadService | Get single pipeline |
| `PipelineV2FindObjects()` | PipelineV2ReadService | Get pipeline objects |
| `PipelineV2DownloadLogs()` | PipelineV2ReadService | Download pipeline logs |
| `PipelineV2GetDashboardUrl()` | PipelineV2ReadService | Get monitoring dashboard URL |
### 2.3 Pipelines Service (Legacy)
**File:** `src/modules/pipelines/pipelines.service.ts`
**Uses:** `PipelinesClientService`
**Methods:**
- `getPipelineStatus()` - Get pipeline execution status
- `runPipeline()` - Trigger pipeline execution
### 2.4 Pipelines Client Service (Legacy)
**File:** `src/modules/pipelines/client.service.ts`
**gRPC Methods Called:**
| Method | Service | Description |
|--------|---------|-------------|
| `getPipelineStatus()` | PipelineService | Get pipeline status |
| `triggerPipeline()` | PipelineService | Trigger pipeline run |
---
## 3. NESTJS MODULES (Module Registration)
| Module | File | Client Configuration | Exports |
|--------|------|---------------------|---------|
| CatalogModule | `src/modules/catalog/catalog.module.ts` | CatalogClientConfiguration | CatalogService |
| PipelinesV2Module | `src/modules/pipelinesV2/pipelines.module.ts` | PipelinesClientConfiguration | PipelinesService |
| PipelinesModule | `src/modules/pipelines/pipelines.module.ts` | PipelinesClientConfiguration | PipelinesService, PipelinesClientService |
**App Module Registration:** `src/app.module.ts` (Lines 20, 23, 25, 53-54, 59)
---
## 4. REST CONTROLLERS (API Endpoints to Migrate)
### 4.1 Catalog Controller
**File:** `src/modules/catalog/catalog.controller.ts`
**Base Path:** `/catalog`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| GET | `/catalog` | Search data assets |
| GET | `/catalog/download` | Download assets as CSV |
| GET | `/catalog/data-asset` | Get asset by pipeline/object |
| GET | `/catalog/data-asset/:id` | Get single data asset |
| GET | `/catalog/data-asset/rls/:id` | Get data asset RLS info |
| GET | `/catalog/data-asset/:id/columns-metadata` | Get column metadata |
| GET | `/catalog/data-asset/:id/preview` | Get data preview |
| GET | `/catalog/data-asset/:id/docs` | Get documentation |
| GET | `/catalog/tags` | Get all tags |
| PUT | `/catalog/data-asset/:id` | Update data asset |
| PUT | `/catalog/data-asset/:id/manage-permissions` | Manage permissions |
| PUT | `/catalog/data-asset/:id/revoke-permissions` | Revoke permissions |
| POST | `/catalog` | Create data asset |
| POST | `/catalog/data-asset/:id/docs` | Create documentation |
| POST | `/catalog/data-asset/:id/comment` | Add comment |
| POST | `/catalog/dataset-catalog-task` | Trigger catalog task |
| POST | `/catalog/rls-rule` | Add RLS rule |
| POST | `/catalog/register-dataset` | Register dataset with metadata |
| DELETE | `/catalog/data-asset/:id` | Delete data asset |
| DELETE | `/catalog/data-asset/:id/comment` | Delete comment |
| DELETE | `/catalog/rls-rule/:id` | Remove RLS rule |
| DELETE | `/catalog/rls-rule` | Batch remove RLS rules |
| GET | `/catalog/dataset-catalog-task/:session` | Get catalog task status |
| GET | `/catalog/rls-rule/:id` | Get single RLS rule |
| GET | `/catalog/rls-rule` | Get RLS rules |
| GET | `/catalog/nimbus-dashboards` | Get Nimbus dashboards |
| GET | `/catalog/pii-reporter` | Get PII report |
**Total: 27 endpoints**
### 4.2 Pipelines V2 Controller
**File:** `src/modules/pipelinesV2/pipelines.controller.ts`
**Base Path:** `/pipelinesV2`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| GET | `/pipelinesV2/monitoring-dashboard` | Get monitoring dashboard URL |
| GET | `/pipelinesV2` | List all pipelines |
| GET | `/pipelinesV2/download-logs` | Download pipeline logs |
| GET | `/pipelinesV2/:id` | Get single pipeline |
| GET | `/pipelinesV2/:id/config` | Get pipeline properties |
| GET | `/pipelinesV2/:id/objects` | Get pipeline objects |
| GET | `/pipelinesV2/:id/status` | Get pipeline status (legacy) |
| POST | `/pipelinesV2` | Create pipeline |
| POST | `/pipelinesV2/init-upload` | Initialize file upload |
| POST | `/pipelinesV2/complete-upload` | Complete file upload |
| POST | `/pipelinesV2/file` | Upload file pipeline |
| POST | `/pipelinesV2/start/:id` | Start pipeline |
| PATCH | `/pipelinesV2/:id` | Update pipeline |
| PUT | `/pipelinesV2/:id` | Update pipeline (deprecated) |
| DELETE | `/pipelinesV2/:id` | Delete pipeline |
**Total: 15 endpoints**
### 4.3 Pipelines Controller (Legacy)
**File:** `src/modules/pipelines/pipelines.controller.ts`
**Base Path:** `/pipelines`
| HTTP Method | Endpoint | Description |
|-------------|----------|-------------|
| POST | `/pipelines/start/:id` | Start pipeline (deprecated) |
| GET | `/pipelines/:id/status` | Get pipeline status (deprecated) |
**Total: 2 endpoints (deprecated)**
---
## 5. CONFIGURATION FILES
### 5.1 Helm Chart Values (Production)
**File:** `deploy/helm-chart/values.yaml`
```yaml
# Line 40
maestro:
pi_factory_url: pi-factory.dadosfera.ai
```
### 5.2 Helm Chart Values (Staging)
**File:** `deploy/helm-chart/values-stg.yaml`
```yaml
# Line 4
maestro:
pi_factory_url: pi-factory.stg.dadosfera.ai
```
### 5.3 Helmfiles (Production)
**File:** `deploy/helmfiles/prd.yaml`
```yaml
# Lines 14, 38
maestro.pi_factory_url: pi-factory.dadosfera.ai
```
### 5.4 Deployment Template
**File:** `deploy/helm-chart/templates/deployment.yaml`
```yaml
# Line 85
- name: PIFACTORY_URL
value: {{ .Values.maestro.pi_factory_url }}
```
### 5.5 Environment Type Definition
**File:** `environment.d.ts`
```typescript
// Line 10
PIFACTORY_URL: string;
```
---
## 6. PROTO PACKAGE DEPENDENCIES
### 6.1 Package.json
**File:** `package.json`
```json
{
"@dadosfera/protospack": "2.5.3", // Legacy pipelines
"@dadosfera/protospack-v2": "3.38.0-beta.14" // PipelineV2, Catalog, PlatformInterfaces
}
```
### 6.2 Proto Imports from `@dadosfera/protospack-v2` (PI-Factory related)
| Import Path | Used In |
|-------------|---------|
| `Catalog` | catalog/catalog-client.ts, catalog/catalog.service.ts |
| `Catalog/interfaces/messages` | catalog/catalog.service.ts, catalog/catalog.controller.ts, catalog/dtos |
| `PlatformInterfaces` | catalog/catalog-client.ts, catalog/catalog.service.ts |
| `PipelineV2` | pipelinesV2/pipelines-client.ts, pipelinesV2/pipelines.service.ts |
| `PipelineV2/interfaces/messages` | pipelinesV2/pipelines.service.ts, pipelinesV2/pipelines.controller.ts |
### 6.3 Proto Imports from `@dadosfera/protospack` (Legacy)
| Import Path | Used In |
|-------------|---------|
| `PipelinePackages` | pipelines/pipelines-client.ts |
| `PipelineProtoFilePath` | pipelines/pipelines-client.ts |
| `PipelineServicesNames` | pipelines/client.service.ts |
| `PipelinesServiceInterface` | pipelines/client.service.ts |
---
## 7. FILES TO MODIFY/DELETE (Summary)
### High Priority - Core Integration Files
```
src/modules/catalog/catalog-client.ts → DELETE or REPLACE
src/modules/catalog/catalog.service.ts → REPLACE with local implementation
src/modules/pipelinesV2/pipelines-client.ts → DELETE or REPLACE
src/modules/pipelinesV2/pipelines.service.ts → REPLACE with local implementation
src/modules/pipelines/pipelines-client.ts → DELETE or REPLACE
src/modules/pipelines/client.service.ts → REPLACE with local implementation
src/modules/pipelines/pipelines.service.ts → REPLACE with local implementation
```
### Medium Priority - Module Registration
```
src/modules/catalog/catalog.module.ts → UPDATE imports
src/modules/pipelinesV2/pipelines.module.ts → UPDATE imports
src/modules/pipelines/pipelines.module.ts → UPDATE imports
src/app.module.ts → UPDATE if module structure changes
```
### Low Priority - Configuration
```
deploy/helm-chart/values.yaml → REMOVE pi_factory_url
deploy/helm-chart/values-stg.yaml → REMOVE pi_factory_url
deploy/helmfiles/prd.yaml → REMOVE pi_factory_url references
deploy/helm-chart/templates/deployment.yaml → REMOVE PIFACTORY_URL env var
environment.d.ts → REMOVE PIFACTORY_URL type
README.md → UPDATE architecture diagram
```
---
## 8. DEPENDENCY COUNT BY MODULE
| Module | Files | gRPC Calls | REST Endpoints |
|--------|-------|------------|----------------|
| Catalog | 3 | 24+ | 27 |
| PipelinesV2 | 3 | 10 | 15 |
| Pipelines (Legacy) | 3 | 2 | 2 |
| **TOTAL** | **9** | **36+** | **44** |
---
## 9. COMPARISON: PI-Factory vs In-Factory
| Aspect | PI-Factory | In-Factory |
|--------|------------|------------|
| Environment Variable | `PIFACTORY_URL` | `INFACTORY_URL` |
| Modules | 3 | 6 |
| gRPC Calls | 36+ | 27+ |
| REST Endpoints | 44 | 25 |
| Complexity | HIGH | MEDIUM-HIGH |
| Domain | Pipelines, Catalog | Connections, Connectors, Inputs |
---
## 10. DATA MODELS (Proto Messages Used)
### Catalog Messages
- `CreateDataAssetRequest` / `CreateDataAssetResponse`
- `GetAllDataAssetsRequest` / `GetAllDataAssetsResponse`
- `GetOneDataAssetRequest` / Response
- `UpdateDataAssetRequest` / Response
- `DeleteDataAssetRequest` / Response
- `ManagePermissionRequest` / Response
- `RevokePermissionRequest` / Response
- `MakeACommentRequest` / Response
- `UpdateACommentRequest` / Response
- `TriggerDatasetCatalogingRequest` / Response
- `GetDatasetCatalogTaskRequest` / Response
- `AddRlsRuleRequest` / Response
- `RemoveRlsRuleRequest` / Response
- `GetRlsRulesRequest` / Response
- `GetNimbusDashboardsRequest` / Response
- `PiiMetadata`
- `RegisterDatasetWithMetatadaRequest`
### PipelineV2 Messages
- `PipelineV2CreateRequest` / `PipelineV2CreateResponse`
- `PipelineV2FindAllRequest` / `PipelineV2FindAllResponse`
- `PipelineV2FindOneRequest` / `PipelineV2FindOneResponse`
- `PipelineV2UpdateRequest` / `PipelineV2UpdateResponse`
- `PipelineV2RemoveRequest` / Response
- `PipelineV2UploadFileRequest` / Response
- `PipelineV2CompleteUploadFileRequest` / Response
- `PipelineV2FindObjectsRequest` / Response
- `PipelineV2DownloadLogsRequest` / Response
- `PipelineV2GetDashboardUrlRequest` / Response
### Platform Interfaces Messages
- `CatalogDataAssetsRequest` / Response
---
## 11. MIGRATION STRATEGY OPTIONS
### Option A: Move PI-Factory functionality INTO Maestro
**Pros:**
- Single service to maintain
- No network latency for these operations
- Simpler deployment
**Cons:**
- Significantly increases Maestro's responsibility
- Requires database access from Maestro
- Large refactoring effort (44 endpoints)
### Option B: Create REST API wrapper in PI-Factory
**Pros:**
- Minimal changes to Maestro
- Can migrate incrementally
**Cons:**
- Still maintains dependency
- Additional REST→gRPC translation layer
### Option C: Direct database access from Maestro
**Pros:**
- Removes runtime dependency
- Better performance
**Cons:**
- Shared database coupling
- Complex migration
---
## 12. RECOMMENDED MIGRATION ORDER
Given the complexity, we recommend migrating in this order:
1. **Legacy Pipelines** (2 endpoints, deprecated) - Lowest risk
2. **PipelinesV2** (15 endpoints) - Core pipeline functionality
3. **Catalog** (27 endpoints) - Most complex, migrate last
### Phase 1: Legacy Pipelines (Deprecated)
- Remove `/pipelines/start/:id`
- Remove `/pipelines/:id/status`
- Update all clients to use `/pipelinesV2/*` endpoints
### Phase 2: PipelinesV2
- Migrate pipeline CRUD operations
- Migrate file upload functionality
- Migrate monitoring dashboard
### Phase 3: Catalog
- Migrate data asset CRUD
- Migrate permissions management
- Migrate RLS rules
- Migrate dataset registration
- Migrate PII reporting
---
## NEXT STEPS
1. **Decide on migration strategy** (Option A, B, or C)
2. **Deprecate legacy pipelines module** first
3. **Create database schema** if moving to Option A
4. **Implement local services** one module at a time
5. **Update tests** for each migrated module
6. **Update deployment configs** to remove PIFACTORY_URL
7. **Coordinate with PI-Factory team** for data migration
+1 -1
View File
@@ -2,9 +2,9 @@
<image src="./assets/maestro.svg" style="width:10rem">
</p>
# Maestro
Maestro é a API principal da Dadosfera. É responsável pela comunicação do Frontend com nossos microsserviços.
```mermaid
@@ -48,6 +48,9 @@ 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
@@ -104,6 +107,12 @@ 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: STORAGE_EXPLORER_API_URL
value: {{ .Values.maestro.storage_explorer_api_url | quote }}
- name: JWT_PRIVATE_KEY
valueFrom:
secretKeyRef:
@@ -124,3 +133,14 @@ 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
+12
View File
@@ -38,3 +38,15 @@ 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
+2
View File
@@ -8,6 +8,8 @@ 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
storage_explorer_api_url: "http://storage-explorer-{customer}.data-apps.svc.cluster.local:8000/api"
hostname: maestro.stg.dadosfera.ai
+6
View File
@@ -27,6 +27,9 @@ 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
@@ -43,11 +46,14 @@ 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"
autoscaling:
enabled: false
+2426 -573
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@ declare global {
OPEN_CUSTOMER_ID: string;
DEDICATED_PROXY: string;
COOKIE_SECRET: string;
REDIS_TLS?: string;
}
}
}
+2537 -1798
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -27,10 +27,15 @@
"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": "2.5.3",
"@dadosfera/protospack-v2": "3.38.0-beta.14",
"@dadosfera/protospack-v2": "3.38.0-beta.28",
"@grpc/grpc-js": "^1.9.3",
"@grpc/proto-loader": "^0.7.9",
"@nestjs/cli": "^9.5.0",
@@ -44,7 +49,7 @@
"@nestjs/schematics": "^9.2.0",
"@nestjs/swagger": "^6.3.0",
"@nestjs/testing": "^9.4.3",
"axios": "^0.27.2",
"axios": "^0.30.2",
"cache-manager": "^5.1.4",
"cache-manager-ioredis-yet": "^1.1.0",
"class-transformer": "^0.5.1",
@@ -75,7 +80,13 @@
"swagger-ui-express": "^4.6.3"
},
"overrides": {
"multer": "1.4.5-lts.1"
"multer": "2.0.2",
"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"
},
"devDependencies": {
"@types/cache-manager": "^4.0.6",
+4
View File
@@ -33,6 +33,8 @@ 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';
@Module({
providers: [
@@ -73,6 +75,8 @@ import { ApiKeyModule } from './modules/api-key/api-key.module';
ApiKeyModule,
IdentityProviderModule,
NetworkPolicyModule,
PlatformApiModule,
StorageExplorerModule,
//Always leave HealthModule last, so it is on the bottom of swagger
HealthModule,
],
+29
View File
@@ -668,6 +668,35 @@ 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;
+1
View File
@@ -111,3 +111,4 @@ function configureSwagger(app: INestApplication) {
);
}
bootstrap();
+57 -100
View File
@@ -13,6 +13,7 @@ import {
Req,
Param,
Res,
UnauthorizedException,
} from '@nestjs/common';
import {
ApiHeaders,
@@ -54,6 +55,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';
type CookiesValues = {
accessToken?: string;
@@ -73,6 +75,7 @@ export class AuthController {
@Inject(DadosferaLogger)
dadosferaLogger: DadosferaLogger,
private authClient: AuthClientService,
private apiKeyService: ApiKeyService,
) {
this.logger = dadosferaLogger.logger;
@@ -102,14 +105,13 @@ export class AuthController {
const data = await this.authClient.signIn({ username, password, totp }, metadata);
if (data.tokens) {
this.addTokenInCookie(res, {
this.authClient.writeAuthSession(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);
@@ -126,27 +128,8 @@ export class AuthController {
) {
try {
this.logger.info('/auth - SignOut');
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');
this.authClient.cleanUpAuthSession(res);
return res.send();
} catch (error) {
@@ -175,8 +158,9 @@ export class AuthController {
const data = await this.authClient.refreshAccessToken({ refreshToken, userId }, metadata);
this.addTokenInCookie(res, {
this.authClient.writeAuthSession(res, {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
userId
});
@@ -208,13 +192,14 @@ export class AuthController {
) {
this.logger.info('/auth - change-password');
const { oldPassword, newPassword } = body;
const { oldPassword, newPassword, totpCode } = body;
const { authorization: accessToken } = headers;
return this.authClient.changePassword({
accessToken,
oldPassword,
newPassword,
totpCode,
});
}
@@ -232,7 +217,8 @@ export class AuthController {
const { username } = body;
return this.authClient.resetPassword({ username }, metadata);
await this.authClient.resetPassword({ username }, metadata);
return { authProvider: process.env.AUTH_PROVIDER || 'cognito' };
}
@ApiInternalOnlyEndpoint()
@@ -492,85 +478,56 @@ export class AuthController {
@Get('me')
async getMe(@Req() req: Request, @Res() res: Response) {
this.logger.info('GET /auth/me ')
// Lê cookies
const accessToken = req.cookies['ddf-auth'];
const userId = req.cookies['ddf-user-id'];
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 = {
id: api_key.user_id,
name: api_key.username,
customer: {
id: api_key.customer_id,
name: api_key.customer_name,
tier: api_key.customer_tier,
}
};
this.logger.info('Has cookie: ' + Boolean(accessToken))
try {
// Decodifica e valida o JWT de acesso
const userDto = await this.authClient.extractUserFrom(accessToken);
return res.status(200).json(userDto);
} catch (err) {
this.logger.error(err.message);
const refreshToken = req.cookies['ddf-refresh-auth'];
}
this.logger.info('Token is invalid')
this.logger.info('Has Refresh Token: '+ Boolean(refreshToken))
// Se access token inválido, tenta refresh
if (!refreshToken || !userId) {
// Get token and headers
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);
if (!hasUserSession) {
throw new UnauthorizedException()
}
try {
const userDto = await this.authClient.validateUserSession(accessToken, resourceHost);
return res.status(200).json(userDto);
} catch (error) {
if (!refreshToken) {
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 userDto = await this.authClient.extractUserFrom(accessToken);
return res.status(200).json(userDto);
} catch (refreshErr) {
this.logger.error(refreshErr)
return res.status(401).json({ error: 'Not authenticated' });
}
}
}
throw new UnauthorizedException("Invalid refresh token or customer name");
};
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: '.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
});
const {
authSession,
user
} = await this.authClient.refreshUserSession(refreshToken, userId, resourceHost);
this.authClient.writeAuthSession(res, authSession);
return res.status(200).json(user);
}
}
}
+2 -1
View File
@@ -8,10 +8,11 @@ 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])],
imports: [ClientsModule.register([client.providerOptions]), ApiKeyModule],
controllers: [AuthController],
providers: [
AuthClientService,
+211 -50
View File
@@ -1,10 +1,20 @@
import { OnModuleInit, Inject, Injectable, ForbiddenException } from '@nestjs/common';
import {
OnModuleInit,
Inject,
Injectable,
ForbiddenException,
HttpException,
HttpStatus,
} 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, IdentityProviderProtoService, UsersProtoService } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service';
import {
AuthProtoService as AuthServiceInterface,
UsersProtoService,
} from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service';
import {
AuthSnowflakeSignInRequest,
AuthSignInRequest,
@@ -22,12 +32,18 @@ import {
import { DucClient } from '../duc/client.config';
import { Metadata } from '@grpc/grpc-js';
import { BulkEditResponse, UserDTO } from './dtos/login';
import jwt from 'jsonwebtoken';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
import { Request, Response } from 'express';
type AuthSession = {
accessToken?: string;
refreshToken?: string;
userId?: string;
};
@Injectable()
export class AuthClientService implements OnModuleInit {
logger: DadosferaLogger;
private authService: AuthServiceInterface;
@@ -63,11 +79,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) {
@@ -75,7 +91,9 @@ 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();
@@ -94,7 +112,6 @@ 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);
@@ -105,7 +122,7 @@ export class AuthClientService implements OnModuleInit {
this.checkDedicatedProxy(result);
}
return result
return result;
}
async refreshAccessToken(
@@ -115,7 +132,10 @@ export class AuthClientService implements OnModuleInit {
this.logger.info('RefreshAccessToken');
return lastValueFrom(
this.authService.AuthRefreshAccessToken({ refreshToken, userId }, metadata),
this.authService.AuthRefreshAccessToken(
{ refreshToken, userId },
metadata,
),
);
}
@@ -123,6 +143,7 @@ export class AuthClientService implements OnModuleInit {
accessToken,
oldPassword,
newPassword,
totpCode,
}: AuthChangePasswordRequest) {
this.logger.info('ChangePassword');
@@ -131,6 +152,7 @@ export class AuthClientService implements OnModuleInit {
accessToken,
oldPassword,
newPassword,
totpCode,
}),
);
}
@@ -284,50 +306,189 @@ export class AuthClientService implements OnModuleInit {
}
}
private async getUser(id: string, metadata: Metadata) {
this.logger.info('getUser');
return await lastValueFrom(
this.userService.UserFindOneById({ id }, metadata),
);
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 extractUserFrom(token: string) {
const decoded: any = token && jwt.decode(token, { complete: true });
if (!decoded) throw new Error('Invalid token');
public async refreshUserSession(
refreshToken: string,
userId: string,
originHeader: string,
): Promise<{
user: UserDTO;
authSession: AuthSession;
}> {
const metadata = PackTheMetadata({});
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);
this.logger.info('Call Refresh Token');
const refreshCredentials = await this.refreshAccessToken(
{ refreshToken, userId },
metadata,
);
this.logger.info('Finish Refresh Token');
const payload = decoded.payload;
const metadata = PackTheMetadata({
customer_id: payload.customer_id
})
const userDto = await this.validateUserSession(
refreshCredentials.accessToken,
originHeader,
);
return {
user: userDto,
authSession: {
accessToken: refreshCredentials.accessToken,
refreshToken: refreshCredentials.refreshToken,
userId,
},
};
}
const {
user
} = await this.getUser(
payload.user_id,
metadata
);
public writeAuthSession(res: Response, data: AuthSession) {
let exp = 1000 * 60 * 5; // 5 minutes
const userDto: UserDTO = {
id: user.id,
name: user.username,
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,
}
};
if (data.accessToken) {
const { exp: expiration } = jwt.decode(data.accessToken) as JwtPayload;
exp = (expiration - 30) * 1000; // exp em segundos, maxAge em ms
return userDto;
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.username,
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,
},
};
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;
}
}
+17 -5
View File
@@ -423,6 +423,7 @@ export class CatalogController {
@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;
@@ -439,7 +440,7 @@ export class CatalogController {
language,
});
const docs = await this.catalogService.getDataDocs(id, metadata);
const docs = await this.catalogService.getDataDocs(id, asset_type, metadata);
return { docs };
}
@@ -487,21 +488,32 @@ export class CatalogController {
@Headers() headers,
@Param('id') table_id: string,
@Body('docs') docs: string,
@Query('asset_type') asset_type: string,
) {
const { user_id, customer_name } = user;
const { user_id, customer_name, customer_id, username } = user;
const metadata = PackTheMetadata({
customer_id,
customer_name,
user_id,
username,
});
this.logger.info(`/catalog - ON GET DATA DOCS ROUTE`, {
this.logger.info(`/catalog - ON POST DATA DOCS ROUTE`, {
user_id,
customer_name,
});
const res = await this.catalogService.createDataDocs({
const body = {
table_id,
docs,
asset_type,
info: {
customer: customer_name,
},
});
}
const res = await this.catalogService.createDataDocs(body, metadata);
return res;
}
+258 -53
View File
@@ -24,9 +24,11 @@ import { CatalogClientConfiguration } from './catalog-client';
import { UsersService } from '../users/users.service';
import { RolesService } from '../roles/roles.service';
import { Metadata } from '@grpc/grpc-js';
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
import {
AssetReporter,
BatchRemoveRlsRulesRequest,
CreateDataDocsDTO,
IUpdateDataRequest,
TriggerCatalogReq,
} from './dtos';
@@ -85,25 +87,23 @@ class CatalogService implements OnModuleInit {
}
async getPiiReporter(metadata: Metadata, type: TypeParser) {
this.logger.info('getPiiReporter: ' + type)
this.logger.info('getPiiReporter: ' + type);
try {
const {
data
} = await lastValueFrom(
this.catalogWriteService.GetPiiReporter({}, metadata)
)
this.logger.info("Finish grpc call")
const { data } = await lastValueFrom(
this.catalogWriteService.GetPiiReporter({}, metadata),
);
this.logger.info('Finish grpc call');
const parser = ParserBuilder.build<PiiMetadata>(type);
this.logger.info('parser file to: ' + type)
const file = await parser.parse(data)
this.logger.info('finish parser')
this.logger.info('parser file to: ' + type);
const file = await parser.parse(data);
this.logger.info('finish parser');
const mimeTypes: Record<TypeParser, string> = {
'csv': 'text/csv',
'html': 'text/html',
'pdf': 'application/pdf'
}
csv: 'text/csv',
html: 'text/html',
pdf: 'application/pdf',
};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `relatorio-pii-${timestamp}.${type}`;
@@ -111,13 +111,12 @@ class CatalogService implements OnModuleInit {
return {
file,
filename: filename,
type: mimeTypes[type]
}
type: mimeTypes[type],
};
} catch (error) {
this.logger.error(error.message);
throw error;
}
}
async createDataAsset(data: Messages.CreateDataAssetRequest, metadata) {
@@ -209,10 +208,99 @@ class CatalogService implements OnModuleInit {
metadata: Metadata,
customer_id: string,
) {
this.logger.info('CatalogService - searchDataAssets');
this.logger.info('CatalogService - searchDataAssets', { query });
const { search, page, size, sort_by, order, ...filters } = query;
this.logger.debug('Extracted filters:', { filters });
console.log('MAESTRO VAI CHAMAR PI-FACTORY COM (ANTES AJUSTE):', {
search,
page,
size,
sort_by,
order,
filters,
});
if (
filters.manually !== undefined &&
filters.manually !== null &&
filters.manually !== ''
) {
filters.manually = Number(filters.manually); // 1 ou 0
} else {
delete filters.manually;
}
console.log('MAESTRO VAI CHAMAR PI-FACTORY COM (DEPOIS AJUSTE):', {
search,
page,
size,
sort_by,
order,
filters,
});
if (filters.owner) {
const { users: customer_users } =
await this.userService.findAllUsersByCustomerId(customer_id);
this.logger.info('Available users in database count:', {
count: customer_users.length,
});
this.logger.info('First 5 users:', {
users: customer_users
.slice(0, 5)
.map((u) => ({ id: u.id, email: u.email, name: u.name })),
});
const ownerValues = Array.isArray(filters.owner)
? filters.owner
: typeof filters.owner === 'string' && filters.owner.includes(',')
? filters.owner.split(',').map((o: string) => o.trim())
: [filters.owner];
this.logger.info('Owner values to convert:', {
ownerValues,
ownerFiltersOriginal: filters.owner,
});
const ownerIds = ownerValues
.map((ownerValue: string) => {
const normalizedOwner = ownerValue.replace(/\s/g, '+');
const user = customer_users.find((u) => {
const isIdMatch = u.id === ownerValue;
const isEmailMatch =
u.email === ownerValue || u.email === normalizedOwner;
const isNameMatch =
u.name === ownerValue || u.name === normalizedOwner;
this.logger.info('Comparing:', {
userId: u.id,
userEmail: u.email,
userName: u.name,
filterValue: ownerValue,
normalizedFilter: normalizedOwner,
idMatch: isIdMatch,
emailMatch: isEmailMatch,
nameMatch: isNameMatch,
});
return isIdMatch || isEmailMatch || isNameMatch;
});
this.logger.info('Looking for owner result:', {
ownerValue,
found: !!user,
userId: user?.id,
});
return user?.id || ownerValue;
})
.filter((id: string) => id);
if (ownerIds.length > 0) {
filters.owner = ownerIds;
}
}
const { data_assets, total } = await lastValueFrom(
this.catalogReadService.GetAllDataAssets(
{
@@ -227,6 +315,8 @@ class CatalogService implements OnModuleInit {
),
);
console.log('MAESTRO RECEBEU RESPOSTA DO PI-FACTORY');
const result = JSON.parse(data_assets);
const response = await this.getAssetsUsersAndRoles(
@@ -244,13 +334,13 @@ class CatalogService implements OnModuleInit {
) {
const data = await this.searchDataAssets(query, metadata, customer_id);
const formatData = data.data_assets.map(asset => ({
const formatData = data.data_assets.map((asset) => ({
id: asset.id,
display_name: asset.display_name,
data_asset_type: asset.data_asset_type,
created_at: asset.created_at,
tags: '[' + asset.tags.join(', ') + ']'
}))
tags: '[' + asset.tags.join(', ') + ']',
}));
const parser = ParserBuilder.build<AssetReporter>('csv');
@@ -261,8 +351,8 @@ class CatalogService implements OnModuleInit {
return {
file,
filename
}
filename,
};
}
async getOneDataAsset(data: {
@@ -337,11 +427,11 @@ class CatalogService implements OnModuleInit {
return { data_asset: asset[0] };
}
async getDataDocs(id: string, metadata: Metadata) {
async getDataDocs(id: string, assetType: string, metadata: Metadata) {
const { documentation } = await lastValueFrom(
this.catalogReadService.GetDatasetDoc({ id, type: undefined }, metadata),
this.catalogReadService.GetDatasetDoc({ id }, metadata),
);
const docs = JSON.parse(documentation);
return docs;
}
@@ -368,7 +458,16 @@ class CatalogService implements OnModuleInit {
return result;
}
async createDataDocs(body) {
async createDataDocs(body: CreateDataDocsDTO, metadata: Metadata) {
if (body.asset_type === 'table' || body.asset_type === 'view') {
return this.createDataDocsViaNimbus(body);
}
return this.createDataDocsViaGrpc(body, metadata);
}
private async createDataDocsViaNimbus(body: CreateDataDocsDTO) {
this.logger.info('Creating data docs via Nimbus for table/view');
const nimbusUrl = this._getNimbusUrl(body);
const { data } = await axios.post(
`${nimbusUrl}/api/catalog/data-docs/`,
@@ -377,6 +476,29 @@ class CatalogService implements OnModuleInit {
return data;
}
private async createDataDocsViaGrpc(body: CreateDataDocsDTO, metadata: Metadata) {
this.logger.info('Creating data docs via gRPC for other asset types');
try {
const response: any = await lastValueFrom(
this.catalogWriteService.UpdateDataAssetDoc(
{
id: body.table_id,
docs: body.docs,
},
metadata,
),
);
return response;
} catch (error) {
this.logger.error('Error creating data asset docs:', error);
throw new HttpException(
'Failed to create data asset documentation',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async findAllTags(data, metadata) {
this.logger.info('CatalogService - findAllCustomerTags');
@@ -394,6 +516,7 @@ class CatalogService implements OnModuleInit {
return response;
}
async getAssetsUsersAndRoles(data_assets: Array<any>, customer_id: string) {
const { users: customer_users } =
await this.userService.findAllUsersByCustomerId(customer_id);
@@ -404,20 +527,20 @@ class CatalogService implements OnModuleInit {
return data_assets.map((data_asset) => {
const owner = customer_users.find(
(u) => u.id === data_asset.owner,
)?.username;
)?.email;
const roles = [];
const users = [];
const data_asset_roles = data_asset?.roles || []
const data_asset_roles = data_asset?.roles || [];
for (const role_id of data_asset_roles) {
const role = customer_roles.find((r) => r.id === role_id);
if (role) roles.push({ id: role.id, name: role.name });
}
const data_asset_users = data_asset?.users || []
const data_asset_users = data_asset?.users || [];
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, username: user.username });
if (user) users.push({ id: user.id, email: user.email });
}
return {
...data_asset,
@@ -507,27 +630,36 @@ class CatalogService implements OnModuleInit {
async createTableMetadata(body: any): Promise<number> {
const nimbusUrl = this._getNimbusUrl(body);
this.logger.info(`Nimbus URL: ${nimbusUrl}`, {...body.logMetadata});
this.logger.info(`Nimbus URL: ${nimbusUrl}`, { ...body.logMetadata });
const endpoint = `${nimbusUrl}/api/catalog/table-metadata/`;
this.logger.info(`Creating table metadata for table ${body.table_metadata.table_name}`, {...body.logMetadata});
this.logger.info(`Using endpoint: ${endpoint}`, {...body.logMetadata});
this.logger.debug(`Payload: ${JSON.stringify(body.table_metadata)}`, {...body.logMetadata});
this.logger.info(
`Creating table metadata for table ${body.table_metadata.table_name}`,
{ ...body.logMetadata },
);
this.logger.info(`Using endpoint: ${endpoint}`, { ...body.logMetadata });
this.logger.debug(`Payload: ${JSON.stringify(body.table_metadata)}`, {
...body.logMetadata,
});
try {
const { data, status } = await axios.post(endpoint, {...body.table_metadata});
const { data, status } = await axios.post(endpoint, {
...body.table_metadata,
});
this.logger.info(
`Table metadata created successfully with status ${status} for table ${body.table_metadata.table_name}`,
{...body.logMetadata},
{ ...body.logMetadata },
);
return data.id;
} catch (error) {
this.logger.error(
`Failed to create table metadata for table ${body.table_metadata.table_name} failed with status ${
error.response?.status
} because of ${JSON.stringify(error.response?.data) || error.message}`, {...body.logMetadata});
} because of ${JSON.stringify(error.response?.data) || error.message}`,
{ ...body.logMetadata },
);
throw new Error(error.response?.data?.message || error.message);
}
}
@@ -537,43 +669,58 @@ class CatalogService implements OnModuleInit {
this.logger.info(`Nimbus URL: ${nimbusUrl}`, body.logMetadata);
const endpoint = `${nimbusUrl}/api/catalog/column-metadata/`;
try {
this.logger.info(`Creating column metadata for table ${body.column_metadata.table_name}`, {...body.logMetadata});
this.logger.info(`Using endpoint: ${endpoint}`, {...body.logMetadata});
this.logger.debug(`Payload: ${JSON.stringify(body.column_metadata)}`, {...body.logMetadata});
const { data, status } = await axios.post(endpoint, body.column_metadata);
this.logger.info(
`Creating column metadata for table ${body.column_metadata.table_name}`,
{ ...body.logMetadata },
);
this.logger.info(`Using endpoint: ${endpoint}`, { ...body.logMetadata });
this.logger.debug(
`Payload: ${JSON.stringify(body.column_metadata)}`,
{ ...body.logMetadata },
);
const { data, status } = await axios.post(
endpoint,
body.column_metadata,
);
this.logger.info(
`Column metadata created successfully with status ${status} for table ${body.column_metadata.table_name}`,
{...body.logMetadata},
{ ...body.logMetadata },
);
return data.map((column) => column.id);
} catch (error) {
this.logger.error(
`Failed to create column metadata failed with status for table ${body.column_metadata.table_name} ${
error.response?.status
} because of ${error.response?.data || error.message}`, {...body.logMetadata});
} because of ${error.response?.data || error.message}`,
{ ...body.logMetadata },
);
throw new Error(error.response?.data?.message || error.message);
}
}
async createDataPreview(body: any): Promise<number> {
const nimbusUrl = this._getNimbusUrl(body);
this.logger.info(`Nimbus URL: ${nimbusUrl}`, {...body.logMetadata});
this.logger.info(`Nimbus URL: ${nimbusUrl}`, { ...body.logMetadata });
const endpoint = `${nimbusUrl}/api/catalog/data-preview/`;
this.logger.info(`Creating data preview for table ${body.data_preview.table_name}`, {...body.logMetadata});
this.logger.info(`Using endpoint: ${endpoint}`, {...body.logMetadata});
this.logger.debug(`Payload: ${JSON.stringify(body.data_preview)}`, {...body.logMetadata});
this.logger.info(
`Creating data preview for table ${body.data_preview.table_name}`,
{ ...body.logMetadata },
);
this.logger.info(`Using endpoint: ${endpoint}`, { ...body.logMetadata });
this.logger.debug(
`Payload: ${JSON.stringify(body.data_preview)}`,
{ ...body.logMetadata },
);
try {
const { data, status } = await axios.post(endpoint, body.data_preview);
this.logger.info(
`Data preview created successfully with status ${status} for table ${body.data_preview.table_name}`,
{...body.logMetadata},
{ ...body.logMetadata },
);
return data.id;
} catch (error) {
@@ -581,12 +728,70 @@ class CatalogService implements OnModuleInit {
`Failed to create data preview for table ${body.data_preview.table_name} failed with status ${
error.response?.status
} because of ${error.response?.data || error.message}`,
{...body.logMetadata},
{ ...body.logMetadata },
);
throw new Error(error.response?.data?.message || error.message);
}
}
async renameTableOnNimbus(
nimbusUrl: string,
nimbusId: number,
changes: { table_name?: string; table_schema?: string; display_name?: string },
): Promise<void> {
const endpoint = `${nimbusUrl}/api/catalog/table-metadata/${nimbusId}`;
this.logger.info(`Renaming table-metadata ${nimbusId} on Nimbus`, { endpoint, changes });
await axios.patch(endpoint, changes);
}
async renameColumnMetadataOnNimbus(
nimbusUrl: string,
databaseName: string,
oldTableName: string,
oldTableSchema: string,
newTableName: string,
newTableSchema: string,
): Promise<void> {
const listEndpoint = `${nimbusUrl}/api/catalog/column-metadata/?database_name=${encodeURIComponent(databaseName)}&table_name=${encodeURIComponent(oldTableName)}&table_schema=${encodeURIComponent(oldTableSchema)}`;
this.logger.info(`Fetching column-metadata records to rename`, { listEndpoint });
const { data: columns } = await axios.get(listEndpoint);
const filtered = Array.isArray(columns) ? columns : [];
for (const column of filtered) {
const patchEndpoint = `${nimbusUrl}/api/catalog/column-metadata/${column.id}`;
await axios.patch(patchEndpoint, {
table_name: newTableName,
table_schema: newTableSchema,
});
}
this.logger.info(`Renamed ${filtered.length} column-metadata records on Nimbus`);
}
async renameDataPreviewOnNimbus(
nimbusUrl: string,
databaseName: string,
oldTableName: string,
oldTableSchema: string,
newTableName: string,
newTableSchema: string,
): Promise<void> {
const listEndpoint = `${nimbusUrl}/api/catalog/data-preview/?database_name=${encodeURIComponent(databaseName)}&table_name=${encodeURIComponent(oldTableName)}&table_schema=${encodeURIComponent(oldTableSchema)}`;
this.logger.info(`Fetching data-preview records to rename`, { listEndpoint });
const { data: previews } = await axios.get(listEndpoint);
const filtered = Array.isArray(previews) ? previews : [];
for (const preview of filtered) {
const patchEndpoint = `${nimbusUrl}/api/catalog/data-preview/${preview.id}`;
await axios.patch(patchEndpoint, {
table_name: newTableName,
table_schema: newTableSchema,
});
}
this.logger.info(`Renamed ${filtered.length} data-preview records on Nimbus`);
}
async catalogDatasetItem(table_metadata_id: number, metadata: Metadata) {
const customer_name_raw = metadata.get('customer_name');
@@ -612,4 +817,4 @@ class CatalogService implements OnModuleInit {
}
}
export { CatalogService };
export { CatalogService };
+31
View File
@@ -98,6 +98,8 @@ export class IDataAsset {
embed?: EmbedObject;
@ApiPropertyOptional({ enum: DataAssetShareType })
share_type?: DataAssetShareType;
@ApiPropertyOptional()
docs?: string;
}
export class IOneDataAsset {
@@ -147,6 +149,24 @@ 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 {
@@ -182,6 +202,8 @@ export class IUpdateDataRequest {
embed: EmbedObject;
@ApiPropertyOptional({ enum: DataAssetShareType })
share_type?: DataAssetShareType;
@ApiPropertyOptional()
docs?: string;
}
export class ICreateDataAsset implements CreateDataAssetRequest {
@ApiProperty()
@@ -196,6 +218,8 @@ export class ICreateDataAsset implements CreateDataAssetRequest {
location: string;
@ApiPropertyOptional()
embed: EmbedObject;
@ApiPropertyOptional()
docs: string;
}
export class IPreview {
@@ -328,3 +352,10 @@ export type AssetReporter = {
created_at: string;
tags: string;
}
export type CreateDataDocsDTO = {
table_id: string;
docs: string;
asset_type: string;
}
+3 -3
View File
@@ -160,7 +160,7 @@ export class ShareService implements OnModuleInit {
});
const { documentation } = await lastValueFrom(
this.catalogReadService.GetDatasetDoc({ id, type: undefined }, metadata),
this.catalogReadService.GetDatasetDoc({ id }, 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,
)?.username;
)?.email;
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, username: user.username });
if (user) users.push({ id: user.id, email: user.email });
}
return {
...data_asset,
@@ -136,4 +136,32 @@ 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);
}
}
+54 -1
View File
@@ -223,4 +223,57 @@ 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;
}
}
}
@@ -0,0 +1,27 @@
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;
}
+7
View File
@@ -14,6 +14,13 @@ export class TableColumns {
@ApiProperty()
references: Column[];
@ApiProperty()
identifier_columns: string[];
@ApiProperty()
destination: Record<'raw' | 'qualify', {
table_name: string;
table_schema: string;
}> | null;
@ApiProperty()
type: string;
}
export class AvailableEntity {
+1 -1
View File
@@ -200,7 +200,7 @@ export class InputsService {
}
async update(id: string, data, info: Info) {
this.validateCron({ ...data, info });
// this.validateCron({ ...data, info });
try {
const updateInputResponse: any = await this.OLD_inputClient.update({
id,
+36
View File
@@ -1,6 +1,15 @@
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { Info } from '@dadosfera/protospack/dist/lib/interfaces';
export class PipelineInputsDTO {
@ApiProperty()
tables: Array<{
name: string,
type: string,
}>
}
export class IPipelineV2 {
@ApiProperty()
id: string;
@@ -123,3 +132,30 @@ 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>;
}
@@ -43,11 +43,15 @@ 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 { TableColumns } from '../inputs/dtos/input.model';
import { UpdateInputRequest } from '../inputs/dtos/old_interfaces';
import { Info } from '@dadosfera/protospack-v2/dist/lib/Input/interfaces/entities';
@ApiTags('PipelinesV2')
@ApiHeaders([{ name: 'dadosfera-lang', enum: LanguageEnum, required: false }])
@@ -223,6 +227,7 @@ export class PipelinesController {
.then((res) => {
//{pipeline:{tables: {tables: [], input_id: ''}}}
let tables = JSON.parse(res.pipeline.config.tables);
const input_id = tables?.input_id;
if (tables?.tables) tables = tables.tables;
Object.assign(res.pipeline, {
transformations: res.pipeline.transformations
@@ -231,6 +236,7 @@ export class PipelinesController {
config: {
cron: res.pipeline.config.cron,
tables,
input_id
},
properties: res.pipeline.properties
? JSON.parse(res.pipeline.properties)
@@ -277,6 +283,45 @@ export class PipelinesController {
return response;
}
@Patch('/:pipelineId/inputs/:id')
@RequireSomePermission(PERMISSIONS_GROUPS.PIPELINE.permissions.UPDATE)
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 { customer_id, customer_name, user_id, username } = user;
const info: Info = {
user_id: user.user_id,
customer: user.customer_name,
customer_id: user.customer_id,
};
const metadata = PackTheMetadata({
customer_id,
customer_name,
user_id,
username,
language,
});
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({
@@ -11,6 +11,7 @@ import { PipelinesModule as OldPipelineModule } from 'src/modules/pipelines/pipe
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';
const client = new PipelinesClientConfiguration();
@@ -21,6 +22,7 @@ const client = new PipelinesClientConfiguration();
ConnectorModule,
InputsModule,
TransformationsModule,
PlatformApiModule
],
controllers: [PipelinesController],
providers: [PipelinesService, DadosferaLogger],
+139 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-async-promise-executor */
import {
BadRequestException,
HttpException,
@@ -16,7 +17,7 @@ import { lastValueFrom } from 'rxjs';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { PipelinesClientConfiguration } from './pipelines-client';
import { ICreatePipelineV2Req } from './interfaces';
import { ICreatePipelineV2Req, UpdatePlatformInputRequest, UpdateTableDTO } 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';
@@ -26,6 +27,8 @@ 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';
export class PipelinesService implements OnModuleInit {
logger: DadosferaLogger;
@@ -39,6 +42,7 @@ export class PipelinesService implements OnModuleInit {
private readonly connectorService: ConnectorClientService,
private readonly inputsService: InputsService,
private readonly transformationsService: TransformationsService,
private readonly platformAPI: PlatformApiService
) {
this.logger = dadosferaLogger.logger;
}
@@ -138,6 +142,7 @@ 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;
@@ -339,4 +344,137 @@ 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');
this.logger.info('Update Dynamo Reference');
const pipelineIdFormat = pipelineId.split('-').join('_');
const updateInputResponse = await this.inputsService.update(
inputId,
updateInputDTO,
info
)
const requests = [];
this.logger.info('Dynamo Response', updateInputResponse);
for (const [index, table] of updateInputDTO.tables.entries()) {
const id = `${pipelineIdFormat}_${index}`;
this.logger.info('Updating input reference for table', table.name);
const body = {}
if (table.columns) {
body['column_include_list'] = table.columns;
}
if (table.reference_column) {
body['incremental_column_name'] = table.reference_column.name;
body['incremental_column_type'] = table.reference_column.type;
}
if (table.identifier_columns) {
body['primary_keys'] = table.identifier_columns;
}
this.logger.info('Request body', body);
const updateCollumns = this.platformAPI.proxy(
'PATCH',
`/jobs/${id}/input`,
user,
body
)
requests.push(updateCollumns);
if (table.memory) {
this.logger.info('Updating memory allocation for table', table.name);
const updateMemory = this.platformAPI.proxy(
'PUT',
`/jobs/${id}/memory`,
user,
{
amount: table.memory
}
)
requests.push(updateMemory);
}
if (table.type) {
const updateSyncMode = this.updatePipelineSyncMode(table, id, user);
requests.push(updateSyncMode);
}
}
this.logger.info('Create Platform Request for each JOB');
if (updateInputDTO.cron) {
const crnUpdatedRequest = new Promise(async (resolve, reject) => {
const response = await this.updatePipelineCron(updateInputDTO.cron, pipelineIdFormat, user);
if (response.error) {
this.logger.error('Error updating pipeline cron', response.error);
return reject(new ErrorBuilder(response.error));
}
this.logger.error('Pipeline cron updated successfully', response);
return resolve(response);
});
requests.push(crnUpdatedRequest);
}
this.logger.info('Executing all request for the platform api');
const results = await Promise.allSettled(requests);
this.logger.info('Platform api response', results);
return updateInputResponse;
}
private async updatePipelineSyncMode(table: UpdateTableDTO, pipelineId: string, user: RequestUser) {
const body = {
target_load_type: table.type
}
if (table.type === 'incremental_with_qualify') {
body['incremental_column_name'] = table.reference_column.name;
body['incremental_column_type'] = table.reference_column.type;
body['primary_keys'] = table.identifier_columns;
}
if (table.type === 'incremental') {
body['incremental_column_name'] = table.reference_column.name;
body['incremental_column_type'] = table.reference_column.type;
}
this.logger.info('Updating pipeline sync mode', {
pipelineId,
body
});
return this.platformAPI.proxy(
"POST",
`/jobs/jdbc/${pipelineId}/sync-mode`,
user,
body
)
}
private async updatePipelineCron(cron: string, pipelineId: string, user: RequestUser) {
try {
const response = await this.platformAPI.proxy(
'PATCH',
`/pipeline/${pipelineId}`,
user,
{
cron
}
);
return response
} catch (error) {
return {
error: error.message
}
}
}
}
@@ -0,0 +1,11 @@
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),
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
export class ValidationTableDTO {
@ApiProperty()
tables: Array<{
table_name: string;
table_schema: string;
}>
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { PlatformApiController } from './platform-api.controller';
import { PlatformApiService } from './platform-api.service';
import { ElasticsearchModule } from '../../services/elasticsearch';
import { DynamoDBModule } from '../../services/dynamodb';
import { CustomersModule } from '../customers/customers.module';
import { CatalogModule } from '../catalog/catalog.module';
@Module({
imports: [ElasticsearchModule, DynamoDBModule, CustomersModule, CatalogModule],
controllers: [PlatformApiController],
providers: [PlatformApiService, DadosferaLogger],
exports: [PlatformApiService],
})
export class PlatformApiModule {}
@@ -0,0 +1,129 @@
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', {
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(),
});
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);
}
}
}
+22 -16
View File
@@ -229,27 +229,33 @@ 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 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,
};
const newRole: RoleDto = this.formatRole(role);
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,
};;
}
}
@@ -0,0 +1,13 @@
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),
};
@@ -0,0 +1,383 @@
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 },
);
}
}
@@ -0,0 +1,13 @@
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 {}
@@ -0,0 +1,178 @@
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);
}
}
}
+14
View File
@@ -38,6 +38,12 @@ export class User {
department?: string;
@ApiProperty()
hierarchy?: string;
@ApiProperty()
bio?: string;
@ApiProperty()
companyName?: string;
@ApiProperty()
personalSite?: string;
@ApiPropertyOptional()
customer?: Customer;
@ApiProperty()
@@ -58,6 +64,8 @@ export class UserNoRolesAndCustomer extends OmitType(UserNoRoles, [
export class IUserByCustomer extends OmitType(User, ['customer']) {
@ApiPropertyOptional()
permissions?: string[];
@ApiPropertyOptional()
authProvider?: string;
}
export class CreateUserReq {
@@ -109,6 +117,12 @@ export class UpdateUserReq {
@ApiPropertyOptional()
hierarchy?: string;
@ApiPropertyOptional()
bio?: string;
@ApiPropertyOptional()
personalSite?: string;
@ApiPropertyOptional()
companyName?: string;
@ApiPropertyOptional()
roleNames?: string[];
}
+12 -1
View File
@@ -266,7 +266,6 @@ export class UsersController {
}
@Patch(':id')
@RequireAllPermissions(PERMISSIONS_GROUPS.USERS.permissions.ADMIN)
@ApiOkResponse({ type: UpdateUserRes })
async updateUser(
@User() user: RequestUser,
@@ -274,6 +273,18 @@ 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);
+11 -7
View File
@@ -125,6 +125,7 @@ export class UsersService implements OnModuleInit {
return { permissions };
});
res.user.permissions = permissions;
res.user.authProvider = process.env.AUTH_PROVIDER || 'cognito';
return res;
}
@@ -148,20 +149,23 @@ export class UsersService implements OnModuleInit {
}
async updateUser(req: UpdateUserReq, id: string, customerId: string) {
const { department, hierarchy, jobTitle, name, roleNames, email } = req;
if (roleNames) {
const { roleNames, ...updateUserDTO } = req;
if (roleNames && roleNames.length > 0) {
await this.setRoles({ roleNames, userId: id }, customerId);
}
const { user } = await lastValueFrom(
this.usersClientService.UserUpdate({
name,
department: updateUserDTO.department,
email: updateUserDTO.email,
hierarchy: updateUserDTO.hierarchy,
jobTitle: updateUserDTO.jobTitle,
name: updateUserDTO.name,
bio: updateUserDTO.bio,
companyName: updateUserDTO.companyName,
personalSite: updateUserDTO.personalSite,
customerId,
id,
department,
hierarchy,
jobTitle,
email,
metabaseUserId: undefined,
}),
);
+4 -1
View File
@@ -14,7 +14,10 @@ export class ValidationPipe implements PipeTransform<any> {
return value;
}
const object = plainToInstance(metatype, value);
const errors = await validate(object);
const errors = await validate(object, {
forbidUnknownValues: false,
whitelist: true,
});
if (errors.length > 0) {
const errorMessages = errors.map((err) => err.constraints);
throw new BadRequestException(errorMessages);
+4
View File
@@ -0,0 +1,4 @@
export const DYNAMODB_CONFIG = {
region: () => process.env.AWS_REGION || 'us-east-1',
inputsTable: () => process.env.INPUTS_DB || 'dadosfera-inputs-prd',
};
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { DynamoDBService } from './dynamodb.service';
@Module({
providers: [DynamoDBService, DadosferaLogger],
exports: [DynamoDBService],
})
export class DynamoDBModule {}
+234
View File
@@ -0,0 +1,234 @@
import { Injectable, Inject } from '@nestjs/common';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
DeleteCommand,
TranslateConfig,
} from '@aws-sdk/lib-dynamodb';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { v4 as uuid } from 'uuid';
import { DYNAMODB_CONFIG } from './dynamodb.config';
export interface ReferenceColumn {
name: string;
type: string;
}
export interface InputDocument {
id: string;
client_id: string;
user_id: string;
created_at: string;
name: string;
description?: string;
plugin: string;
type: string;
tables?: Array<{
name: string;
type: string;
columns?: string[];
reference_column?: ReferenceColumn;
}>;
credentials?: Record<string, any>;
}
@Injectable()
export class DynamoDBService {
private documentClient: DynamoDBDocumentClient;
private logger: any;
constructor(
@Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger,
) {
this.logger = dadosferaLogger.logger;
const dynamoConfig = { region: DYNAMODB_CONFIG.region() };
const marshallOptions: TranslateConfig = {
marshallOptions: {
removeUndefinedValues: true,
},
};
const dynamoDb = new DynamoDBClient(dynamoConfig);
this.documentClient = DynamoDBDocumentClient.from(dynamoDb, marshallOptions);
}
async createInput(
clientId: string,
userId: string,
data: {
name: string;
description?: string;
plugin: string;
type: string;
tables?: Array<{
name: string;
type: string;
columns?: string[];
reference_column?: ReferenceColumn;
}>;
},
): Promise<InputDocument> {
const tableName = DYNAMODB_CONFIG.inputsTable();
const id = uuid();
const created_at = new Date().toISOString();
const item: InputDocument = {
id,
client_id: clientId,
user_id: userId,
created_at,
name: data.name,
description: data.description,
plugin: data.plugin,
type: data.type,
tables: data.tables,
};
this.logger.info('DynamoDB: Creating input', {
tableName,
inputId: id,
plugin: data.plugin,
});
const putCommand = new PutCommand({
TableName: tableName,
Item: item,
});
try {
await this.documentClient.send(putCommand);
this.logger.info('DynamoDB: Input created successfully', { inputId: id });
return item;
} catch (error) {
this.logger.error('DynamoDB: Failed to create input', {
tableName,
inputId: id,
region: DYNAMODB_CONFIG.region(),
error: error.message,
errorName: error.name,
});
throw error;
}
}
async findInput(clientId: string, inputId: string): Promise<InputDocument | null> {
const tableName = DYNAMODB_CONFIG.inputsTable();
const getCommand = new GetCommand({
TableName: tableName,
Key: {
id: inputId,
client_id: clientId,
},
});
const { Item } = await this.documentClient.send(getCommand);
return Item as InputDocument | null;
}
async deleteInput(clientId: string, inputId: string): Promise<void> {
const tableName = DYNAMODB_CONFIG.inputsTable();
this.logger.info('DynamoDB: Deleting input', {
tableName,
inputId,
});
const deleteCommand = new DeleteCommand({
TableName: tableName,
Key: {
id: inputId,
client_id: clientId,
},
});
await this.documentClient.send(deleteCommand);
this.logger.info('DynamoDB: Input deleted successfully', { inputId });
}
/**
* Update a specific table entry in the input document.
* Fetches the current document, updates the matching table, and saves.
*/
async updateInputTable(
clientId: string,
inputId: string,
tableName: string,
changes: {
type?: string;
columns?: string[];
reference_column?: ReferenceColumn | null;
},
): Promise<void> {
const dynamoTableName = DYNAMODB_CONFIG.inputsTable();
this.logger.info('DynamoDB: Updating input table', {
inputId,
tableName,
changes: Object.keys(changes),
});
// Get current document
const current = await this.findInput(clientId, inputId);
if (!current) {
this.logger.warn('DynamoDB: Input not found for update', { inputId });
return;
}
// Find and update the matching table
const tables = current.tables || [];
const tableIndex = tables.findIndex((t) => t.name === tableName);
if (tableIndex === -1) {
this.logger.warn('DynamoDB: Table not found in input', {
inputId,
tableName,
});
return;
}
// Merge changes into the table entry
const updatedTable = { ...tables[tableIndex] };
if ('type' in changes) updatedTable.type = changes.type;
if ('columns' in changes) updatedTable.columns = changes.columns;
if ('reference_column' in changes) {
if (changes.reference_column === null) {
delete updatedTable.reference_column;
} else {
updatedTable.reference_column = changes.reference_column;
}
}
tables[tableIndex] = updatedTable;
// Save updated document
const putCommand = new PutCommand({
TableName: dynamoTableName,
Item: {
...current,
tables,
updated_at: new Date().toISOString(),
},
});
try {
await this.documentClient.send(putCommand);
this.logger.info('DynamoDB: Input table updated successfully', {
inputId,
tableName,
});
} catch (error) {
this.logger.error('DynamoDB: Failed to update input table', {
inputId,
tableName,
error: error.message,
});
throw error;
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './dynamodb.service';
export * from './dynamodb.module';
export * from './dynamodb.config';
@@ -0,0 +1,5 @@
export const ELASTICSEARCH_CONFIG = {
getUrl: () => process.env.ELASTICSEARCH_URL || 'http://localhost:9200',
getApiKey: () => process.env.ELASTICSEARCH_API_KEY || '',
timeout: 10000, // 10 seconds
};
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { ElasticsearchService } from './elasticsearch.service';
@Module({
providers: [ElasticsearchService, DadosferaLogger],
exports: [ElasticsearchService],
})
export class ElasticsearchModule {}
@@ -0,0 +1,457 @@
import { Injectable, Inject } from '@nestjs/common';
import axios, { AxiosInstance, AxiosError } from 'axios';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { ELASTICSEARCH_CONFIG } from './elasticsearch.config';
interface MultiLang {
'en-us': string;
'pt-br': string;
'es-es': string;
}
interface MultiLangArray {
'en-us': string[];
'pt-br': string[];
'es-es': string[];
}
interface ConnectorInfo {
plugin: string;
name: MultiLang;
image: string;
version: string;
tags: string[];
}
interface PipelineDocument {
id: string;
name: MultiLang;
description: MultiLang;
customer_id: string;
user_id: string;
username: string;
status: string;
created_at: string;
updated_at: string;
last_status_updated: string;
tags: string[];
// Connector metadata
connection_id: string;
connector_name: string;
connector_plugin: string;
connector_version: string;
image_url: string;
// Config
config: {
cron: string;
tables?: string;
};
properties: string;
type: string;
in_use: number;
keywords: MultiLangArray;
}
@Injectable()
export class ElasticsearchService {
private client: AxiosInstance;
private logger: any;
constructor(
@Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger,
) {
this.logger = dadosferaLogger.logger;
this.client = axios.create({
baseURL: ELASTICSEARCH_CONFIG.getUrl(),
headers: {
Authorization: `ApiKey ${ELASTICSEARCH_CONFIG.getApiKey()}`,
'Content-Type': 'application/json',
},
timeout: ELASTICSEARCH_CONFIG.timeout,
});
}
private getIndex(customerName: string): string {
return `${customerName}_pipelines`;
}
private formatMultiLang(value: string): MultiLang {
return {
'en-us': value,
'pt-br': value,
'es-es': value,
};
}
private formatMultiLangArray(value: string[] = []): MultiLangArray {
return {
'en-us': value,
'pt-br': value,
'es-es': value,
};
}
buildPipelineDocument(
pipelineId: string,
data: {
name: string;
description?: string;
user_id: string;
username: string;
customer_id: string;
plugin: string;
connection_id: string;
cron?: string;
tables?: string;
properties?: Record<string, any>;
type?: string;
status?: string;
created_at?: string;
keywords?: string[];
},
connector: ConnectorInfo | null,
): PipelineDocument {
const now = new Date().toISOString();
return {
id: pipelineId,
connection_id: data.connection_id,
connector_name: connector?.name?.['en-us'] || '',
connector_plugin: connector?.plugin || data.plugin,
connector_version: connector?.version || '1.0.0',
created_at: data.created_at || now,
updated_at: now,
customer_id: data.customer_id,
description: this.formatMultiLang(data.description || ''),
image_url: connector?.image || '',
keywords: this.formatMultiLangArray(data.keywords),
name: this.formatMultiLang(data.name || ''),
status: data.status || 'CREATED',
user_id: data.user_id,
username: data.username,
config: {
cron: data.cron,
tables: data.tables,
},
properties: data.properties ? JSON.stringify(data.properties) : '{}',
type: data.type,
in_use: 1,
last_status_updated: now,
tags: connector?.tags || [],
};
}
async getConnectorByPlugin(plugin: string): Promise<ConnectorInfo | null> {
this.logger.info('Elasticsearch: Looking up connector', { plugin });
try {
const response = await this.client.post('/connectors/_search', {
query: {
term: { plugin: plugin },
},
size: 1,
});
const hits = response.data.hits?.hits || [];
if (hits.length === 0) {
this.logger.warn('Elasticsearch: Connector not found', { plugin });
return null;
}
const source = hits[0]._source;
return {
plugin: source.plugin,
name: source.name,
image: source.image,
version: source.version,
tags: source.tags || [],
};
} catch (error) {
this.handleError('getConnectorByPlugin', error, { plugin });
return null;
}
}
async createPipeline(
customerName: string,
pipelineId: string,
data: {
name: string;
description?: string;
user_id: string;
username: string;
customer_id: string;
status?: string;
created_at?: string;
plugin: string;
connection_id: string;
cron?: string;
tables?: string;
properties?: Record<string, any>;
type?: string;
},
connector: ConnectorInfo | null,
): Promise<any> {
const index = this.getIndex(customerName);
const document = this.buildPipelineDocument(pipelineId, data, connector);
this.logger.info('Elasticsearch: Creating pipeline', {
index,
pipelineId,
plugin: document.connector_plugin,
});
try {
const response = await this.client.post(
`${index}/_doc/${pipelineId}`,
document,
{ params: { refresh: 'wait_for' } },
);
this.logger.info('Elasticsearch: Pipeline created successfully', {
pipelineId,
result: response.data.result,
});
return response.data;
} catch (error) {
this.handleError('createPipeline', error, { pipelineId, index });
throw error;
}
}
async updatePipeline(
customerName: string,
pipelineId: string,
changes: {
name?: string;
description?: string;
cron?: string;
status?: string;
tags?: string[];
},
): Promise<any> {
const index = this.getIndex(customerName);
const now = new Date().toISOString();
this.logger.info('Elasticsearch: Updating pipeline', {
index,
pipelineId,
fields: Object.keys(changes),
});
try {
// Fetch current document
const currentDoc = await this.client.get(`${index}/_doc/${pipelineId}`);
const current = currentDoc.data._source;
// Build updated document, preserving existing values
const updated: Record<string, any> = {
...current,
updated_at: now,
};
if ('name' in changes) {
updated.name = this.formatMultiLang(changes.name);
}
if ('description' in changes) {
updated.description = this.formatMultiLang(changes.description);
}
if ('cron' in changes) {
updated.config = {
...current.config,
cron: changes.cron,
};
}
if ('status' in changes) {
updated.status = changes.status;
updated.last_status_updated = now;
}
if ('tags' in changes) {
updated.tags = changes.tags;
}
const response = await this.client.post(
`${index}/_doc/${pipelineId}`,
updated,
{ params: { refresh: 'wait_for' } },
);
this.logger.info('Elasticsearch: Pipeline updated successfully', {
pipelineId,
result: response.data.result,
});
return response.data;
} catch (error) {
this.handleError('updatePipeline', error, { pipelineId, index });
throw error;
}
}
async getPipeline(
customerName: string,
pipelineId: string,
): Promise<PipelineDocument | null> {
const index = this.getIndex(customerName);
this.logger.info('Elasticsearch: Getting pipeline', {
index,
pipelineId,
});
try {
const response = await this.client.get(`${index}/_doc/${pipelineId}`);
return response.data._source as PipelineDocument;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 404) {
this.logger.warn('Elasticsearch: Pipeline not found', {
pipelineId,
index,
});
return null;
}
this.handleError('getPipeline', error, { pipelineId, index });
throw error;
}
}
async deletePipeline(
customerName: string,
pipelineId: string,
): Promise<any> {
const index = this.getIndex(customerName);
this.logger.info('Elasticsearch: Deleting pipeline', {
index,
pipelineId,
});
try {
const response = await this.client.delete(
`${index}/_doc/${pipelineId}`,
{ params: { refresh: 'wait_for' } },
);
this.logger.info('Elasticsearch: Pipeline deleted successfully', {
pipelineId,
result: response.data.result,
});
return response.data;
} catch (error) {
// If document not found, log warning but don't throw
if (error instanceof AxiosError && error.response?.status === 404) {
this.logger.warn('Elasticsearch: Pipeline not found for deletion', {
pipelineId,
index,
});
return { result: 'not_found' };
}
this.handleError('deletePipeline', error, { pipelineId, index });
throw error;
}
}
private getDataAssetIndex(customerName: string): string {
return `${customerName}_data_assets_catalog`;
}
async findDataAssetByTable(
customerName: string,
tableName: string,
tableSchema: string,
): Promise<{ id: string; nimbus_id: number | null; [key: string]: any } | null> {
const index = this.getDataAssetIndex(customerName);
this.logger.info('Elasticsearch: Searching data asset', {
index,
tableName,
tableSchema,
});
try {
const response = await this.client.post(`/${index}/_search`, {
query: {
bool: {
must: [
{ term: { 'table_name.keyword': tableName.toUpperCase() } },
{ term: { 'table_schema.keyword': tableSchema.toUpperCase() } },
],
},
},
size: 1,
});
const hits = response.data.hits?.hits || [];
if (hits.length === 0) {
this.logger.warn('Elasticsearch: Data asset not found', { tableName, tableSchema, index });
return null;
}
return { ...hits[0]._source, _es_id: hits[0]._id };
} catch (error) {
this.handleError('findDataAssetByTable', error, { tableName, tableSchema, index });
throw error;
}
}
async updateDataAsset(
customerName: string,
assetId: string,
updates: Record<string, any>,
): Promise<any> {
const index = this.getDataAssetIndex(customerName);
this.logger.info('Elasticsearch: Updating data asset', {
index,
assetId,
fields: Object.keys(updates),
});
try {
const response = await this.client.post(
`/${index}/_update/${assetId}`,
{ doc: updates },
{ params: { refresh: 'wait_for' } },
);
this.logger.info('Elasticsearch: Data asset updated', {
assetId,
result: response.data.result,
});
return response.data;
} catch (error) {
this.handleError('updateDataAsset', error, { assetId, index });
throw error;
}
}
private handleError(
operation: string,
error: any,
context: Record<string, any>,
): void {
if (error instanceof AxiosError) {
this.logger.error(`Elasticsearch: ${operation} failed`, {
...context,
status: error.response?.status,
statusText: error.response?.statusText,
errorData: error.response?.data,
message: error.message,
});
} else {
this.logger.error(`Elasticsearch: ${operation} failed`, {
...context,
message: error.message,
stack: error.stack,
});
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './elasticsearch.module';
export * from './elasticsearch.service';
export * from './elasticsearch.config';
+1 -1
View File
@@ -14,7 +14,7 @@ import { redisStore } from 'cache-manager-ioredis-yet';
keyPrefix: 'maestro:sso',
}
if (process.env.ENV !== 'local') {
if (process.env.REDIS_TLS === 'true') {
baseRedisConfig['tls'] = {
servername: process.env.REDIS_HOST,
}
+90
View File
@@ -0,0 +1,90 @@
import CronParser from 'cron-parser';
export enum ScheduleLimits {
MINUTE = 'minute',
HOUR = 'hour',
DAY = 'day',
UNLIMITED = 'unlimited',
}
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86400;
/**
* Airflow preset schedules mapped to cron expressions.
* @once is special - it means run only once (no recurring schedule).
*/
const AIRFLOW_PRESETS: Record<string, string | null> = {
'@once': null, // No recurring schedule - always valid
'@hourly': '0 * * * *', // Every hour
'@daily': '0 0 * * *', // Every day at midnight
'@weekly': '0 0 * * 0', // Every week on Sunday
'@monthly': '0 0 1 * *', // First day of every month
'@yearly': '0 0 1 1 *', // First day of every year
'@annually': '0 0 1 1 *', // Same as @yearly
};
/**
* Convert Airflow preset to cron expression.
* Returns null for @once (no recurring schedule).
* Returns original string if not an Airflow preset.
*/
export function convertAirflowPresetToCron(schedule: string): string | null {
const preset = AIRFLOW_PRESETS[schedule.toLowerCase()];
if (preset !== undefined) {
return preset;
}
return schedule;
}
export function getMinimumIntervalSeconds(scheduleLimit: string): number {
switch (scheduleLimit) {
case ScheduleLimits.MINUTE:
return SECONDS_IN_MINUTE;
case ScheduleLimits.HOUR:
return SECONDS_IN_HOUR;
case ScheduleLimits.DAY:
return SECONDS_IN_DAY;
case ScheduleLimits.UNLIMITED:
default:
return 0;
}
}
export function getCronIntervalSeconds(cron: string): number {
const interval = CronParser.parseExpression(cron);
const nextDate = interval.next().toDate();
const afterNextDate = interval.next().toDate();
return Math.floor((afterNextDate.getTime() - nextDate.getTime()) / 1000);
}
export function validateCronAgainstScheduleLimit(
cron: string,
scheduleLimit: string,
): { valid: boolean; message?: string } {
if (!cron) return { valid: true };
// Convert Airflow presets to cron expressions
const cronExpression = convertAirflowPresetToCron(cron);
// @once returns null - no recurring schedule, always valid
if (cronExpression === null) {
return { valid: true };
}
try {
const cronInterval = getCronIntervalSeconds(cronExpression);
const minInterval = getMinimumIntervalSeconds(scheduleLimit);
if (cronInterval < minInterval) {
return {
valid: false,
message: `Schedule interval (${cronInterval}s) is below customer limit (${scheduleLimit}: ${minInterval}s minimum)`,
};
}
return { valid: true };
} catch (error) {
return { valid: false, message: `Invalid cron expression: ${error.message}` };
}
}