Compare commits

..
Author SHA1 Message Date
viniciusgadea b0ec305763 FIX: update connectionTestListSchemas to map cached columns and derive references from the engine allowlist 2026-09-04 08:19:42 -03:00
viniciusgadea 4543c1ad05 FIX: enhance getTableMetadata to filter columns by allowed data types 2026-09-03 12:19:08 -03:00
Rafael Santana ec39b82843 Merge pull request #518 from dadosfera/feature/cdc-connector
FIX: CDC live status decodes int64 offsets as numbers
2026-08-31 17:07:20 -03:00
RafaelandWOZCODE 20d3532c0f FIX: address review round 2 on the CDC refactor
- toCdcTable accepts the source table named either table_name (platform
  bodies) or name (create DTO), so call sites pass it point-free:
  body.tables.map(toCdcTable). The identity is still derived in one place.
- PipelineTablesService declares its logger like every other maestro service
  (logger: DadosferaLogger assigned from the injected instance).

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-31 16:09:25 -03:00
Rafael Santana 9457fcc85e Merge pull request #519 from dadosfera/refactor/cdc-review
UPDATE: apply the CDC code-review policy (maestro #510)
2026-08-31 12:52:51 -03:00
RafaelandWOZCODE 73aa3504a6 UPDATE: add CLAUDE.md with the development policy from the CDC review
Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-29 13:01:35 -03:00
RafaelandWOZCODE 2347f65b78 UPDATE: one platform route for table removal; table orchestration moves to PipelineTablesService
Review (maestro #510): batch and CDC tables are both removed through
DELETE /pipeline/{id}/jobs (the platform dispatches by type: Airflow refresh
vs Kafka Connect reconfigure), so the connector 'if' in the controller is
gone. deleteTable/deleteTables/addTable are now thin controller methods over
PipelineTablesService (mark -> resolve jobs -> platform -> rollback), with
typed request bodies.

Behavior change for batch: the platform refuses to remove the LAST table of
a pipeline (400 'Cannot remove all jobs'), where DELETE /jobs/{id} allowed it.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-29 12:57:49 -03:00
RafaelandWOZCODE 23f0554311 UPDATE: type CDC input calls against the protospack gRPC contracts
Review (maestro #510): drop every 'as any' on the Input write client
(AddCdcTable/RemoveCdcTable/UnmarkTableDeleted take the generated request
types), type IPipelineV2.config, and build the CdcTable payload in one mapper
so 'name' mirrors table_name in a single place. isCdc via helper, no '!!'.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-29 12:56:00 -03:00
RafaelandWOZCODE 27eed59a1f UPDATE: PipelineExecutionGuard decides by the pipeline's explicit connector type
Review (maestro #510): the guard inferred 'editable' from the absence of run
history. It now reads GET /pipeline/{id} (DB only) and short-circuits on
job.input.connector === 'cdc'; batch keeps the pipeline_run check. Adds
src/utils/cdc.ts as the single isCdc* helper.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-29 12:48:28 -03:00
RafaelandWOZCODE 688615dbee UPDATE: bump protospack-v2 to 3.40.0-beta.21 (live-status sink offsets)
Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-29 12:16:18 -03:00
RafaelandWOZCODE ce48e69162 FIX: decode int64 as Number in the PipelineV2 gRPC client
PipelineV2SinkTableOffset.committed_offset is int64. Without a `longs`
option proto-loader decodes it as a Long.js object ({low,high,unsigned}),
which then serialized to the frontend as an object and blew up the card's
DecimalPipe (NG02100) on every 10s poll — the visible symptom was the
raw/deduped layers flickering. Kafka offsets fit in 2^53, so `longs: Number`
is exact and keeps the JSON a plain number.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-28 19:45:06 -03:00
Rafael Santana 1400df0ab9 Merge pull request #510 from dadosfera/feature/cdc-connector
Feature/cdc connector
2026-08-28 13:36:54 -03:00
RafaelandWOZCODE b0fa8d29fc test(maestro): set INFACTORY_URL in the test Docker target
Beta's cache-first work added connection-test.service.spec.ts, whose import
graph (connection/client.config.ts) reads process.env.INFACTORY_URL at load
time. The Dockerfile test target only set DUC_URL, so that suite crashed at
import ("Cannot read properties of undefined (reading 'startsWith')") in CI and
in any bare `npm test` run.

Add ENV INFACTORY_URL=0.0.0.0:50052 alongside the existing DUC_URL, matching the
local-connection convention (0.0.0.0 => no SSL). Full suite: 52 passed, 5 skipped.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-28 10:32:56 -03:00
RafaelandWOZCODE 5c609a8df8 test(maestro): fix connection-test merge test failures
- platform-api.controller.spec: addCdcTable expectations now include the CDC
  fields the controller threads (iceberg_table_name, iceberg_qualify_table_name,
  column_exclude_list) which were added by the CDC-iceberg work.
- release_note specs: provide DadosferaLogger mock — ReleaseNoteService gained an
  @Inject(DadosferaLogger) dependency (from beta) without its specs being updated,
  so they failed DI resolution on merge.

Full suite: 52 passed, 5 skipped, 0 failed.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-28 09:38:20 -03:00
RafaelandWOZCODE 28a11c961a fix(cdc): allow CDC plugins in RefreshCatalogReq validator
Beta's refresh-catalog RefreshCatalogReq DTO restricted plugin to
oracle/mysql/postgresql/sqlserver. Under the cache-first catalog model
(adopted for CDC in the beta merge), the CDC schema-fetch flow posts
/connection-test/refresh-catalog with plugin=mysql_cdc, which the @IsIn
rejected ("plugin must be one of: oracle, mysql, postgresql, sqlserver").

Add mysql_cdc/postgresql_cdc/oracle_cdc to the @IsIn and @ApiProperty enum,
matching the platform connection-test SQS plugin set. docsfera.json regenerated.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-27 20:13:00 -03:00
RafaelandWOZCODE 107938aa19 Merge origin/beta into feature/cdc-connector
Resolves conflicts for PR #510 (base: beta):
- package.json: keep protospack ^3.40.0-beta.20 (carries CDC→Iceberg fields);
  package-lock.json reconciled (protospack was the only dep delta vs beta).
- connection-test controller/service/dto: keep BOTH feature sets — our CDC
  prerequisites validation AND beta's refresh-catalog endpoints.
- Adopt beta's cache-first catalog reads (connections-api proxy) over our gRPC
  path. connectionTestListTables now enriches each table with primary_keys
  derived from the cached columns endpoint (is_primary_key), preserving the
  CDC create flow's need for PKs under the new architecture; spec updated.
- docsfera.json resolved to ours; regenerated on next app bootstrap.

connection-test spec: 6 passed. tsc --noEmit: clean.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-27 19:37:31 -03:00
RafaelandWOZCODE 23a633badf UPDATE: switch protospack-v2 to published @3.40.0-beta.20
Replaces the local file:../protospack-v2/...cdc-iceberg.4.tgz tarball reference
with the published CodeArtifact version ^3.40.0-beta.20 (carries the CDC→Iceberg
qualify_namespace / iceberg_qualify_table_name / column_exclude_list proto fields).

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-27 19:14:26 -03:00
vinicius gadea c1f5c013ee Merge pull request #514 from dadosfera/feat/migration-nimbus-data-beta
Feat/migration nimbus data beta
2026-08-25 17:27:45 -03:00
viniciusgadea ba609a9494 FEAT: add data_preview field to data asset creation in CatalogService 2026-08-25 17:14:22 -03:00
viniciusgadea 8916dfc46c FEAT: update @dadosfera/protospack-v2 dependency to version 3.40.0-beta.19 2026-08-25 17:05:34 -03:00
viniciusgadea cf4e0aea22 FEAT: update protospack-v2 dependency to version 3.40.0-beta.18 and refactor UpdateDataAsset method in CatalogService 2026-08-25 14:06:25 -03:00
viniciusgadea 9d57e69e74 Merge remote-tracking branch 'origin/beta' into feat/migration-nimbus-data-beta
# Conflicts:
#	docsfera.json
#	src/modules/catalog/dtos/index.ts
2026-08-25 12:08:47 -03:00
viniciusgadea 4db15fceab FEAT: add request body and DTOs for updating columns metadata in catalog 2026-08-25 09:36:02 -03:00
viniciusgadea a4f4335e43 FEAT: update columns metadata endpoint to accept batch updates and add error handling for missing columns 2026-08-25 09:05:46 -03:00
RafaelandWOZCODE bf0314f5b1 FIX: trigger release for /auth/me permission seqids (PR #513)
PR #513 merged to beta but no semantic-release ran: its commits used
conventional-commits prefixes (feat(auth):, fix(auth):) which the
.releaserc.json eslint preset does not recognise, so commit-analyzer
found no release-worthy change. This empty FIX: commit matches the
eslint preset's releaseRules (tag FIX -> patch) to cut a beta release
that includes the /auth/me permission-seqids change, so stg can deploy it.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-24 19:18:59 -03:00
Rafael Santana c97180ac95 Merge pull request #513 from dadosfera/feat/auth-me-orchest-identity
feat(auth): return the user's permission seqids from /auth/me
2026-08-24 17:00:15 -03:00
RafaelandWOZCODE cac36f2c60 refactor(auth): /auth/me returns raw permission seqids
Return payload.permissions verbatim (numeric seqids) instead of
translating them to claim strings. Consumers own the seqid->meaning
mapping. Drops permission-claims.ts entirely; UserDTO.permissions is
now number[].

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-24 16:56:23 -03:00
RafaelandWOZCODE cd21fd0b7b refactor(auth): /auth/me returns permissions only (drop roles/modules)
Keep Maestro a pure identity provider: /auth/me exposes the user's
permission claim strings and nothing consumer-specific. Consumers derive
whatever meaning they need (roles, module access, groups) from the claim
vocabulary — claims are already namespaced group:action.

- UserDTO: drop roles[]/modules[], keep permissions[].
- Helper shrinks to a generic seqid->claim translation
  (orchest-identity.ts -> permission-claims.ts, translateSeqidsToClaims).
- api-key branch: permissions: [] only.

The roles/modules derivation moves entirely to the consumer (Orchest's
auth-server adapter).

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-24 16:30:33 -03:00
RafaelandWOZCODE a5a685ee3f fix(auth): derive Orchest identity from numeric seqids (JWT carries seqids not claim strings)
The JWT `permissions` claim is an array of numeric seqids at runtime
(see authentication.guard.ts / authentication.decorator.ts), not claim
strings. deriveOrchestIdentity previously matched claim strings against
this numeric array, so roles[]/modules[] were always empty for every
real user.

- deriveOrchestIdentity now takes number[] | undefined and matches
  seqids sourced from PERMISSIONS_GROUPS (permissions.enum.ts) instead
  of hand-copied literals.
- permissions is translated back to claim strings via a full
  seqid->claim catalog built once from PERMISSIONS_GROUPS; unknown
  seqids are dropped (auth-server ignores permissions[] in v1).
- auth.controller.ts's api-key branch literal is now annotated
  `: UserDTO` so tsc enforces the three fields there.
- Both spec files re-fixtured with numeric seqid inputs, including a
  mixed admin+module case and an exact claim-string translation
  assertion.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-24 16:03:20 -03:00
Rafael a16fefe691 feat(auth): return permissions/roles/modules from /auth/me (all branches) 2026-08-24 15:45:09 -03:00
Rafael 9c57485031 feat(auth): pure helper deriving Orchest identity from permissions 2026-08-24 15:39:40 -03:00
RafaelandWOZCODE 5de033ec19 feat(cdc-iceberg): thread qualify namespace/table through maestro
Threads the new CDC→Iceberg fields from the REST DTOs to the gRPC calls:
- CdcTableReq.iceberg_qualify_table_name + IcebergDestinationReq.qualify_namespace
  in input.model.ts
- inputs.service.ts create map forwards iceberg_qualify_table_name
- platform-api.controller.ts addTable body + cdcTable thread iceberg_qualify_table_name

Bumps protospack to v3.41.0-cdc-iceberg.4; docsfera.json regenerated with the new
/platform/iceberg/{namespaces,tables/validate} routes.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-24 09:08:36 -03:00
RafaelandWOZCODE 4457c0ae62 feat(cdc): accept + forward source columns on /inputs/cdc
Adds CdcColumnReq {name, type, is_primary_key} and columns? on
CdcTableReq, forwarded through createCdc and the addTable (Edit
Objects add-table) path so the column schema reaches in-factory for
Iceberg deduped-table pre-create. Bumps protospack-v2 to
3.41.0-cdc-iceberg.1, which adds the matching CdcColumn field
(now required on CdcTable) and regenerates docsfera.json.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-21 18:48:11 -03:00
RafaelandWOZCODE d119f0d955 test(inputs): add service-level test for CDC create field-mapping
InputsService.createCdc builds InputCreateCdcRequest by enumerating
fields (not spreading), so a revert of the destination/iceberg_table_name
mapping lines would not be caught by the existing controller spec, which
only mocks InputsService. Add a unit test at the service boundary that
asserts destination and per-table iceberg_table_name reach the gRPC
request, plus a back-compat case with no destination.

Also mark CdcTableReq.iceberg_table_name as advisory/reserved: the
platform derives the Iceberg table name itself today and does not yet
consume this field.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-21 11:46:45 -03:00
RafaelandWOZCODE b3bbf2473a FEAT(cdc): carry iceberg_table_name through the add-tables endpoint
The POST /pipelines/:pipelineId/inputs/:inputId/tables route built its
CdcTable payload field-by-field and silently dropped iceberg_table_name
even though inputsService.addCdcTable/the gRPC AddCdcTable call (and the
protospack CdcTable message) already support it. Widen the inline request
body type and thread the field into the addCdcTable payload; absent for
snowflake, unchanged back-compat.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-21 11:29:54 -03:00
RafaelandWOZCODE 7de207c676 FEAT(cdc): carry iceberg destination through /inputs/cdc DTOs
Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-21 11:13:27 -03:00
RafaelandWOZCODE 997eef876d CHORE(cdc): point protospack at local tarball 3.41.0-cdc-iceberg.0 (dev-only)
Unblocks the CdcDestination contract locally on the CDC branch. Swap to a
published registry version before merge (CI guard blocks file: deps).

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-21 11:10:24 -03:00
viniciusgadea 6efc25ad5e FEAT: add endpoint to update column description in catalog 2026-08-20 08:26:56 -03:00
RafaelandWOZCODE ead3fa12dc feat(cdc): batch table removal — reconfigure connectors once
Removing N tables via Edit Objects previously looped a single-table
DELETE per table (maestro deleteTable hardcodes job_ids:[one]), so the
Debezium source + Snowflake sink connectors were rewritten/restarted once
per table. platform-api's DELETE /pipeline/:id/jobs already batches (2
connector writes total for any N), but nothing above it used the array.

New maestro DELETE /pipelines/:pipelineId/inputs/:inputId/tables takes
{ table_names: [] }: soft-deletes each in DynamoDB (tracking successes),
resolves all table_names -> job_ids from the platform pipeline in one GET,
then makes ONE DELETE /pipeline/:id/jobs with all job_ids. All-or-nothing:
any failure (a later mark, an unmatched table, or the platform delete)
rolls back only the marks made in this call.

The single-table deleteTable route is kept (unchanged) — nothing else
depends on removing it, and that's a separable cleanup.

Tests: N tables -> one platform DELETE with all job_ids and no per-job
call; rollback on platform failure; rollback + no delete when a later mark
fails; 404 for an unmatched table. 8 controller specs pass; maestro builds.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-19 10:47:06 -03:00
RafaelandWOZCODE 714c1334b9 chore(deps): use published protospack-v2 3.40.0-beta.16
Switch @dadosfera/protospack-v2 from the local file: tarball (removed from
the protospack-v2 repo) to the CI-published CodeArtifact version. Installs
and builds clean; test suites pass.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-19 09:49:43 -03:00
Rafael 55308420ff feat(cdc): addTable route — DynamoDB-first + rollback 2026-08-17 17:01:05 -03:00
RafaelandWOZCODE fab2061efc chore: bump protospack-v2 to cdc.4
Co-Authored-By: WOZCODE <contact@withwoz.com>
Claude-Session: https://claude.ai/code/session_01145m1zZMfx8RSJBxAhySdg
2026-08-17 16:16:32 -03:00
RafaelandWOZCODE f1d539a912 fix: PipelineExecutionGuard allows edits when pipeline has no run history
The guard read `status[status.length-1].last_status` on the pipeline_run
history. For a pipeline that has never run — which is the permanent state
of CDC pipelines, since they replicate continuously and never record
batch runs — pipeline_run is empty, so the last element is undefined and
`.last_status` threw, surfacing as "Error checking pipeline status:
Cannot read properties of undefined (reading 'last_status')". This
blocked Edit Objects (and delete-table) for every CDC pipeline.

Treat an empty/statusless run history as "not running" and allow the
edit (undefined never satisfied 'running' anyway). Also stop the catch
from double-wrapping the deliberate is-running BadRequestException, so
that rejection keeps its clear message; genuine status-check failures
still fail closed with the wrapped message (safe default for a
destructive-op gate).

Adds a guard spec: empty history -> allow, statusless -> allow, not
running -> allow, running -> is-running message (not wrapped), platform
error -> wrapped message.

Co-Authored-By: WOZCODE <contact@withwoz.com>
Claude-Session: https://claude.ai/code/session_01145m1zZMfx8RSJBxAhySdg
2026-08-17 13:35:19 -03:00
RafaelandWOZCODE c85e2ac5da feat: forward create body config over gRPC (cdc.3) for CDC destinations
The pipeline create body carries `config.tables[].destinations`, which
the CDC path in pi-factory needs to honor a user-supplied raw Snowflake
table name. The gRPC PipelineV2CreateRequest previously had no `config`
field, so `...body` dropped it on the wire.

Bump protospack to cdc.3 (adds optional `config` string). Serialize
`body.config` into the create request the same way `properties` is
handled, and add `config?` to the ICreatePipelineV2Req DTO so it's
typed. Add a spec asserting the gRPC request carries a stringified
config with the destination intact.

Co-Authored-By: WOZCODE <contact@withwoz.com>
Claude-Session: https://claude.ai/code/session_01145m1zZMfx8RSJBxAhySdg
2026-08-17 11:33:29 -03:00
RafaelandWOZCODE 0d771d1e4a FIX: skip batch job-updates for CDC in updatePipelineInput
updatePlatformJobs pushes batch sync_mode/memory to jobs by positional
index — meaningless for CDC and corrupting. CDC add/remove use dedicated
endpoints, so skip updatePlatformJobs for CDC inputs. The input record
update still runs.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-16 22:04:00 -03:00
RafaelandWOZCODE 7bd269950f FIX: CDC table removal reconfigures the Debezium connector
deleteTable called the DB-row-only DELETE /jobs/{id}, leaving the CDC
connector still replicating a removed table. For CDC jobs, call
DELETE /pipeline/{id}/jobs (RemoveJobsUsecase) with delete_snowflake_tables
false so replication stops but landed data is kept. Batch path unchanged.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-16 22:00:39 -03:00
RafaelandWOZCODE 017fd145a9 CHORE: bump protospack-v2 to cdc.2 + pass name through createCdc
CdcTable.name is now required; map it (== table_name) in maestro createCdc.

Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-16 21:56:34 -03:00
RafaelandWOZCODE fc4e1c27e4 chore(maestro): regenerate swagger spec for tables[].primary_keys
Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-16 16:58:00 -03:00
RafaelandWOZCODE 27e0dedea4 feat(maestro): expose tables[].primary_keys on /connection-test/tables
Co-Authored-By: WOZCODE <contact@withwoz.com>
2026-08-16 16:57:29 -03:00
RafaelandWOZCODE a597c41070 feat(cdc): expose CDC routes over REST (on main + beta protospack)
Re-applied onto fresh origin/main. pipelinesV2: live-status/pause/unpause/
restart/reset-state routes (per-route @RequireSomePermission — GET for status,
UPDATE for mutations — matching main's current auth convention). connection-test:
POST /connection-test/cdc-prerequisites. inputs: POST /inputs/cdc -> InputCreateCdc.
Consumes protospack 3.40.0-beta.15-cdc.0 tarball; CI guard added.

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

Some files were not shown because too many files have changed in this diff Show More