pipework 0.251.0 → 0.252.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/core/config/load.d.ts +2 -0
- package/dist/core/config/load.d.ts.map +1 -1
- package/dist/core/config/load.js +1 -0
- package/dist/core/config/load.js.map +1 -1
- package/dist/core/config/namespace.d.ts +1 -0
- package/dist/core/config/namespace.d.ts.map +1 -1
- package/dist/core/config/schema.d.ts +2 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +1 -0
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/surface/types.d.ts +1 -0
- package/dist/core/surface/types.d.ts.map +1 -1
- package/dist/data/db/namespace.d.ts +3 -0
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +3 -0
- package/dist/data/db/namespace.js.map +1 -1
- package/dist/data/db/pool.d.ts.map +1 -1
- package/dist/data/db/pool.js +6 -1
- package/dist/data/db/pool.js.map +1 -1
- package/dist/data/db/query-label.d.ts +5 -0
- package/dist/data/db/query-label.d.ts.map +1 -0
- package/dist/data/db/query-label.js +11 -0
- package/dist/data/db/query-label.js.map +1 -0
- package/dist/data/db/query-watch.d.ts +21 -0
- package/dist/data/db/query-watch.d.ts.map +1 -0
- package/dist/data/db/query-watch.js +90 -0
- package/dist/data/db/query-watch.js.map +1 -0
- package/dist/data/query/postgres-js/driver.js +1 -1
- package/dist/data/query/postgres-js/driver.js.map +1 -1
- package/dist/data/query/postgres-js/session.d.ts +5 -1
- package/dist/data/query/postgres-js/session.d.ts.map +1 -1
- package/dist/data/query/postgres-js/session.js +16 -11
- package/dist/data/query/postgres-js/session.js.map +1 -1
- package/dist/data/query/utils.d.ts +2 -0
- package/dist/data/query/utils.d.ts.map +1 -1
- package/dist/data/query/utils.js.map +1 -1
- package/dist/infra/credits/schema.d.ts +1 -1
- package/dist/test/plugin.d.ts.map +1 -1
- package/dist/test/plugin.js +10 -4
- package/dist/test/plugin.js.map +1 -1
- package/package.json +4 -3
- package/src/async/jobs/abortable-delay.ts +18 -0
- package/src/async/jobs/claims.ts +52 -0
- package/src/async/jobs/cron.ts +179 -0
- package/src/async/jobs/errors.ts +56 -0
- package/src/async/jobs/exclusion.ts +117 -0
- package/src/async/jobs/execute.ts +219 -0
- package/src/async/jobs/index.ts +10 -0
- package/src/async/jobs/listener.ts +135 -0
- package/src/async/jobs/namespace.ts +20 -0
- package/src/async/jobs/queue-door.ts +44 -0
- package/src/async/jobs/queue.ts +1012 -0
- package/src/async/jobs/run-script.ts +67 -0
- package/src/async/jobs/scheduler.ts +218 -0
- package/src/async/jobs/schedules-table.ts +37 -0
- package/src/async/jobs/serialize-error.ts +48 -0
- package/src/async/jobs/table.ts +191 -0
- package/src/async/pipeline/define.ts +53 -0
- package/src/async/pipeline/errors.ts +35 -0
- package/src/async/pipeline/execute.ts +53 -0
- package/src/async/pipeline/index.ts +13 -0
- package/src/async/pipeline/namespace.ts +12 -0
- package/src/async/pipeline/resume.ts +67 -0
- package/src/async/pipeline/run-steps.ts +98 -0
- package/src/async/pipeline/types.ts +57 -0
- package/src/async/state-machine/define.ts +64 -0
- package/src/async/state-machine/errors.ts +29 -0
- package/src/async/state-machine/index.ts +3 -0
- package/src/async/state-machine/namespace.ts +12 -0
- package/src/async/state-machine/transition.ts +90 -0
- package/src/async/state-machine/types.ts +37 -0
- package/src/async/steps/errors.ts +31 -0
- package/src/async/steps/graph.ts +1214 -0
- package/src/async/steps/hash.ts +40 -0
- package/src/async/steps/index.ts +7 -0
- package/src/async/steps/namespace.ts +7 -0
- package/src/async/steps/table.ts +97 -0
- package/src/async/steps/types.ts +318 -0
- package/src/audit/chain/index.ts +9 -0
- package/src/audit/chain/install.ts +113 -0
- package/src/audit/chain/retire.ts +52 -0
- package/src/audit/chain/verify.ts +257 -0
- package/src/audit/query/cursor.ts +37 -0
- package/src/audit/query/index.ts +9 -0
- package/src/audit/query/query.ts +223 -0
- package/src/audit/schema/audit-record-fields.ts +68 -0
- package/src/audit/schema/audit-record.ts +23 -0
- package/src/auth/auth/audit.ts +31 -0
- package/src/auth/auth/chain.ts +84 -0
- package/src/auth/auth/cookie.ts +136 -0
- package/src/auth/auth/email-links.ts +311 -0
- package/src/auth/auth/guard.ts +128 -0
- package/src/auth/auth/index.ts +18 -0
- package/src/auth/auth/multi-org.ts +337 -0
- package/src/auth/auth/namespace.ts +34 -0
- package/src/auth/auth/parse-expiry.ts +13 -0
- package/src/auth/auth/passwords.ts +110 -0
- package/src/auth/auth/schema.ts +110 -0
- package/src/auth/auth/sessions.ts +565 -0
- package/src/auth/auth/sign-in.ts +156 -0
- package/src/auth/auth/signing-keys.ts +69 -0
- package/src/auth/auth/types.ts +26 -0
- package/src/auth/auth/uuid.ts +10 -0
- package/src/auth/rbac/cache.ts +55 -0
- package/src/auth/rbac/check.ts +30 -0
- package/src/auth/rbac/create.ts +35 -0
- package/src/auth/rbac/index.ts +5 -0
- package/src/auth/rbac/middleware.ts +20 -0
- package/src/auth/rbac/namespace.ts +15 -0
- package/src/auth/rbac/resolve.ts +31 -0
- package/src/auth/rbac/schema.sql +31 -0
- package/src/auth/rbac/seed.ts +67 -0
- package/src/auth/rbac/tables.ts +32 -0
- package/src/auth/rbac/types.ts +65 -0
- package/src/auth/tenant/extract.ts +33 -0
- package/src/auth/tenant/in-tenant.ts +49 -0
- package/src/auth/tenant/index.ts +7 -0
- package/src/auth/tenant/namespace.ts +23 -0
- package/src/auth/tenant/propagate.ts +20 -0
- package/src/auth/tenant/rls.ts +120 -0
- package/src/auth/tenant/scope.ts +33 -0
- package/src/auth/tenant/scoped-db.ts +581 -0
- package/src/auth/tenant/types.ts +14 -0
- package/src/auth/tenant/validate.ts +27 -0
- package/src/cli/commands/add.ts +158 -0
- package/src/cli/commands/alerts.ts +98 -0
- package/src/cli/commands/check.ts +1418 -0
- package/src/cli/commands/codemod.ts +78 -0
- package/src/cli/commands/db.ts +356 -0
- package/src/cli/commands/dev.ts +101 -0
- package/src/cli/commands/generate.ts +75 -0
- package/src/cli/commands/init.ts +36 -0
- package/src/cli/commands/install.ts +28 -0
- package/src/cli/commands/lint.ts +62 -0
- package/src/cli/commands/migrate.ts +132 -0
- package/src/cli/commands/openapi.ts +63 -0
- package/src/cli/commands/remove.ts +72 -0
- package/src/cli/commands/run.ts +50 -0
- package/src/cli/commands/scope.ts +54 -0
- package/src/cli/commands/serve.ts +68 -0
- package/src/cli/commands/shard.ts +31 -0
- package/src/cli/commands/shared/config-pipework-version.ts +28 -0
- package/src/cli/commands/shared/find-config.ts +22 -0
- package/src/cli/commands/shared/load-config.ts +35 -0
- package/src/cli/commands/shared/pnpm-install.ts +95 -0
- package/src/cli/commands/shared/report-removed.ts +8 -0
- package/src/cli/commands/sync.ts +32 -0
- package/src/cli/commands/test.ts +307 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/dev-bootstrap.ts +24 -0
- package/src/cli/errors.ts +10 -0
- package/src/cli/index.ts +303 -0
- package/src/cli/output.ts +17 -0
- package/src/cli/port.ts +15 -0
- package/src/cli/surface-kinds.ts +20 -0
- package/src/client/index.ts +4 -0
- package/src/client/poll.ts +130 -0
- package/src/client/report-fault.ts +119 -0
- package/src/core/config/app-pool.ts +19 -0
- package/src/core/config/aws-env.ts +29 -0
- package/src/core/config/base-dir.ts +24 -0
- package/src/core/config/discover.ts +64 -0
- package/src/core/config/env-resolve.ts +87 -0
- package/src/core/config/env-types.ts +116 -0
- package/src/core/config/env.ts +55 -0
- package/src/core/config/errors.ts +9 -0
- package/src/core/config/import-config.ts +40 -0
- package/src/core/config/index.ts +8 -0
- package/src/core/config/load.ts +417 -0
- package/src/core/config/namespace.ts +14 -0
- package/src/core/config/resolve.ts +195 -0
- package/src/core/config/schema.ts +488 -0
- package/src/core/config/statement-observer.ts +48 -0
- package/src/core/config/ts-inline-hooks.ts +75 -0
- package/src/core/config/ts-register.ts +4 -0
- package/src/core/config/ts-resolve-hooks.ts +101 -0
- package/src/core/errors/base.ts +49 -0
- package/src/core/errors/http.ts +146 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/namespace.ts +31 -0
- package/src/core/lifecycle/drain.ts +33 -0
- package/src/core/lifecycle/health.ts +72 -0
- package/src/core/lifecycle/index.ts +4 -0
- package/src/core/lifecycle/namespace.ts +7 -0
- package/src/core/lifecycle/startup.ts +102 -0
- package/src/core/lifecycle/types.ts +36 -0
- package/src/core/logging/context.ts +60 -0
- package/src/core/logging/error-chain.ts +68 -0
- package/src/core/logging/index.ts +5 -0
- package/src/core/logging/logger.ts +65 -0
- package/src/core/logging/namespace.ts +16 -0
- package/src/core/logging/proxy.ts +22 -0
- package/src/core/logging/redact-match.ts +24 -0
- package/src/core/logging/types.ts +47 -0
- package/src/core/pipework.ts +566 -0
- package/src/core/runtime/broker.ts +69 -0
- package/src/core/surface/http.ts +133 -0
- package/src/core/surface/index.ts +11 -0
- package/src/core/surface/job-timeout.ts +40 -0
- package/src/core/surface/namespace.ts +13 -0
- package/src/core/surface/script.ts +33 -0
- package/src/core/surface/start.ts +142 -0
- package/src/core/surface/types.ts +225 -0
- package/src/core/surface/validate.ts +88 -0
- package/src/core/surface/worker.ts +528 -0
- package/src/core/validation/connectivity.ts +49 -0
- package/src/core/validation/domain.ts +99 -0
- package/src/core/validation/index.ts +5 -0
- package/src/core/validation/queue-tables.ts +312 -0
- package/src/core/validation/rls-role.ts +138 -0
- package/src/core/validation/startup.ts +51 -0
- package/src/core/validation/surfaces.ts +65 -0
- package/src/core/validation/types.ts +25 -0
- package/src/data/db/advisory-lock.ts +55 -0
- package/src/data/db/aggregates.ts +172 -0
- package/src/data/db/array-elements.ts +111 -0
- package/src/data/db/bulk-set.ts +53 -0
- package/src/data/db/col/index.ts +14 -0
- package/src/data/db/composed.ts +53 -0
- package/src/data/db/db.ts +276 -0
- package/src/data/db/excluded.ts +16 -0
- package/src/data/db/expressions.ts +473 -0
- package/src/data/db/filter.ts +90 -0
- package/src/data/db/function-source.ts +114 -0
- package/src/data/db/identifiers.ts +16 -0
- package/src/data/db/idx/index.ts +9 -0
- package/src/data/db/in-list.ts +49 -0
- package/src/data/db/index.ts +9 -0
- package/src/data/db/insert-split.ts +137 -0
- package/src/data/db/instrumentation.ts +82 -0
- package/src/data/db/maintenance.ts +28 -0
- package/src/data/db/namespace.ts +107 -0
- package/src/data/db/owner-labels.ts +9 -0
- package/src/data/db/pool.ts +187 -0
- package/src/data/db/query-label.ts +13 -0
- package/src/data/db/query-watch.ts +116 -0
- package/src/data/db/raw-client-warning.ts +37 -0
- package/src/data/db/readonly.ts +23 -0
- package/src/data/db/reservation.ts +151 -0
- package/src/data/db/schema-features.ts +14 -0
- package/src/data/db/serializable.ts +83 -0
- package/src/data/db/sql.ts +1 -0
- package/src/data/db/timeouts.ts +76 -0
- package/src/data/db/types.ts +18 -0
- package/src/data/db/unbound.ts +33 -0
- package/src/data/db/values.ts +29 -0
- package/src/data/domain/brand.ts +15 -0
- package/src/data/domain/build-factory.ts +193 -0
- package/src/data/domain/build-schema.ts +161 -0
- package/src/data/domain/build-table.ts +355 -0
- package/src/data/domain/define.ts +239 -0
- package/src/data/domain/external-registry.ts +34 -0
- package/src/data/domain/external.ts +77 -0
- package/src/data/domain/field.ts +279 -0
- package/src/data/domain/index.ts +13 -0
- package/src/data/domain/naming.ts +6 -0
- package/src/data/domain/projections.ts +57 -0
- package/src/data/domain/trace-safe-projection.ts +31 -0
- package/src/data/domain/traits.ts +108 -0
- package/src/data/domain/types.ts +289 -0
- package/src/data/jsonb/composed.ts +18 -0
- package/src/data/jsonb/index.ts +1 -0
- package/src/data/jsonb/namespace.ts +30 -0
- package/src/data/jsonb/query.ts +86 -0
- package/src/data/migrate/append-only-generate.ts +47 -0
- package/src/data/migrate/applied.ts +47 -0
- package/src/data/migrate/apply.ts +309 -0
- package/src/data/migrate/check.ts +119 -0
- package/src/data/migrate/diff.ts +357 -0
- package/src/data/migrate/door-generate.ts +130 -0
- package/src/data/migrate/framework-rls.ts +58 -0
- package/src/data/migrate/generate.ts +201 -0
- package/src/data/migrate/history.ts +215 -0
- package/src/data/migrate/index.ts +9 -0
- package/src/data/migrate/internal-definitions.ts +175 -0
- package/src/data/migrate/post-process.ts +73 -0
- package/src/data/migrate/pragma.ts +73 -0
- package/src/data/migrate/provision-framework.ts +147 -0
- package/src/data/migrate/rls-generate.ts +61 -0
- package/src/data/migrate/run.ts +124 -0
- package/src/data/migrate/safety.ts +167 -0
- package/src/data/migrate/snapshot.ts +368 -0
- package/src/data/migrate/sql-emit.ts +392 -0
- package/src/data/migrate/statements.ts +312 -0
- package/src/data/query/alias.ts +126 -0
- package/src/data/query/batch.ts +8 -0
- package/src/data/query/cache/core/cache.ts +78 -0
- package/src/data/query/cache/core/index.ts +1 -0
- package/src/data/query/cache/core/types.ts +29 -0
- package/src/data/query/cache/readme.md +230 -0
- package/src/data/query/casing.ts +76 -0
- package/src/data/query/collation.ts +11 -0
- package/src/data/query/column-builder.ts +370 -0
- package/src/data/query/column.ts +150 -0
- package/src/data/query/entity.ts +42 -0
- package/src/data/query/errors.ts +33 -0
- package/src/data/query/index.ts +14 -0
- package/src/data/query/logger.ts +47 -0
- package/src/data/query/operations.ts +45 -0
- package/src/data/query/pg-core/alias.ts +12 -0
- package/src/data/query/pg-core/checks.ts +32 -0
- package/src/data/query/pg-core/columns/all.ts +71 -0
- package/src/data/query/pg-core/columns/bigint.ts +110 -0
- package/src/data/query/pg-core/columns/bigserial.ts +128 -0
- package/src/data/query/pg-core/columns/boolean.ts +43 -0
- package/src/data/query/pg-core/columns/char.ts +85 -0
- package/src/data/query/pg-core/columns/cidr.ts +43 -0
- package/src/data/query/pg-core/columns/common.ts +366 -0
- package/src/data/query/pg-core/columns/custom.ts +232 -0
- package/src/data/query/pg-core/columns/date.common.ts +15 -0
- package/src/data/query/pg-core/columns/date.ts +112 -0
- package/src/data/query/pg-core/columns/double-precision.ts +55 -0
- package/src/data/query/pg-core/columns/enum.ts +201 -0
- package/src/data/query/pg-core/columns/index.ts +35 -0
- package/src/data/query/pg-core/columns/inet.ts +43 -0
- package/src/data/query/pg-core/columns/int.common.ts +57 -0
- package/src/data/query/pg-core/columns/integer.ts +53 -0
- package/src/data/query/pg-core/columns/interval.ts +83 -0
- package/src/data/query/pg-core/columns/json.ts +58 -0
- package/src/data/query/pg-core/columns/jsonb.ts +56 -0
- package/src/data/query/pg-core/columns/line.ts +117 -0
- package/src/data/query/pg-core/columns/macaddr.ts +43 -0
- package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
- package/src/data/query/pg-core/columns/numeric.ts +227 -0
- package/src/data/query/pg-core/columns/point.ts +127 -0
- package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
- package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
- package/src/data/query/pg-core/columns/real.ts +58 -0
- package/src/data/query/pg-core/columns/serial.ts +55 -0
- package/src/data/query/pg-core/columns/smallint.ts +53 -0
- package/src/data/query/pg-core/columns/smallserial.ts +60 -0
- package/src/data/query/pg-core/columns/text.ts +67 -0
- package/src/data/query/pg-core/columns/time.ts +72 -0
- package/src/data/query/pg-core/columns/timestamp.ts +163 -0
- package/src/data/query/pg-core/columns/uuid.ts +51 -0
- package/src/data/query/pg-core/columns/varchar.ts +89 -0
- package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
- package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
- package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
- package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
- package/src/data/query/pg-core/db.ts +674 -0
- package/src/data/query/pg-core/dialect.ts +1404 -0
- package/src/data/query/pg-core/expressions.ts +25 -0
- package/src/data/query/pg-core/foreign-keys.ts +130 -0
- package/src/data/query/pg-core/index.ts +21 -0
- package/src/data/query/pg-core/indexes.ts +276 -0
- package/src/data/query/pg-core/policies.ts +56 -0
- package/src/data/query/pg-core/primary-keys.ts +62 -0
- package/src/data/query/pg-core/query-builders/count.ts +86 -0
- package/src/data/query/pg-core/query-builders/delete.ts +293 -0
- package/src/data/query/pg-core/query-builders/index.ts +7 -0
- package/src/data/query/pg-core/query-builders/insert.ts +452 -0
- package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
- package/src/data/query/pg-core/query-builders/query.ts +157 -0
- package/src/data/query/pg-core/query-builders/raw.ts +49 -0
- package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
- package/src/data/query/pg-core/query-builders/select.ts +1346 -0
- package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
- package/src/data/query/pg-core/query-builders/update.ts +626 -0
- package/src/data/query/pg-core/roles.ts +41 -0
- package/src/data/query/pg-core/schema.ts +72 -0
- package/src/data/query/pg-core/sequence.ts +41 -0
- package/src/data/query/pg-core/session.ts +292 -0
- package/src/data/query/pg-core/subquery.ts +46 -0
- package/src/data/query/pg-core/table.ts +252 -0
- package/src/data/query/pg-core/unique-constraint.ts +73 -0
- package/src/data/query/pg-core/utils/array.ts +95 -0
- package/src/data/query/pg-core/utils/index.ts +1 -0
- package/src/data/query/pg-core/utils.ts +104 -0
- package/src/data/query/pg-core/view-base.ts +14 -0
- package/src/data/query/pg-core/view-common.ts +1 -0
- package/src/data/query/pg-core/view.ts +435 -0
- package/src/data/query/pg-core/with-builder.ts +41 -0
- package/src/data/query/postgres-js/README.md +46 -0
- package/src/data/query/postgres-js/array-parser.ts +45 -0
- package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
- package/src/data/query/postgres-js/driver.ts +163 -0
- package/src/data/query/postgres-js/index.ts +2 -0
- package/src/data/query/postgres-js/session.ts +234 -0
- package/src/data/query/primary-key.ts +11 -0
- package/src/data/query/query-builders/query-builder.ts +19 -0
- package/src/data/query/query-builders/select.types.ts +175 -0
- package/src/data/query/query-promise.ts +35 -0
- package/src/data/query/relations.ts +725 -0
- package/src/data/query/runnable-query.ts +12 -0
- package/src/data/query/selection-proxy.ts +121 -0
- package/src/data/query/session.ts +8 -0
- package/src/data/query/sql/expressions/conditions.ts +741 -0
- package/src/data/query/sql/expressions/index.ts +2 -0
- package/src/data/query/sql/expressions/select.ts +45 -0
- package/src/data/query/sql/functions/aggregate.ts +129 -0
- package/src/data/query/sql/functions/index.ts +2 -0
- package/src/data/query/sql/functions/vector.ts +178 -0
- package/src/data/query/sql/index.ts +3 -0
- package/src/data/query/sql/sql.ts +794 -0
- package/src/data/query/subquery.ts +53 -0
- package/src/data/query/table.ts +208 -0
- package/src/data/query/table.utils.ts +2 -0
- package/src/data/query/tracing-utils.ts +3 -0
- package/src/data/query/tracing.ts +11 -0
- package/src/data/query/utils.ts +357 -0
- package/src/data/query/version.ts +4 -0
- package/src/data/query/view-common.ts +1 -0
- package/src/data/schema/branded.ts +13 -0
- package/src/data/schema/generated/coerce.ts +5 -0
- package/src/data/schema/generated/combinator.ts +6 -0
- package/src/data/schema/generated/encoding.ts +5 -0
- package/src/data/schema/generated/error.ts +6 -0
- package/src/data/schema/generated/format.ts +5 -0
- package/src/data/schema/generated/type-constructor.ts +10 -0
- package/src/data/schema/generated/type.ts +6 -0
- package/src/data/schema/index.ts +6 -0
- package/src/data/schema/json-schema.ts +33 -0
- package/src/data/schema/namespace.ts +55 -0
- package/src/data/schema/parse.ts +39 -0
- package/src/data/schema/types.ts +39 -0
- package/src/data/temporal/append-only.ts +134 -0
- package/src/data/temporal/close.ts +27 -0
- package/src/data/temporal/columns.ts +10 -0
- package/src/data/temporal/definition-queries.ts +70 -0
- package/src/data/temporal/errors.ts +10 -0
- package/src/data/temporal/get-current.ts +22 -0
- package/src/data/temporal/index.ts +9 -0
- package/src/data/temporal/namespace.ts +36 -0
- package/src/data/temporal/query.ts +25 -0
- package/src/data/temporal/resolve.ts +17 -0
- package/src/data/temporal/revise.ts +67 -0
- package/src/data/temporal/types.ts +22 -0
- package/src/data/valve/artifact.ts +33 -0
- package/src/data/valve/contract-diff.ts +21 -0
- package/src/data/valve/cross.ts +57 -0
- package/src/data/valve/crossing-ledger.ts +50 -0
- package/src/data/valve/decision.ts +44 -0
- package/src/data/valve/durable-crossing.ts +52 -0
- package/src/data/valve/execute-crossing.ts +77 -0
- package/src/data/valve/faucet.ts +56 -0
- package/src/data/valve/gate.ts +114 -0
- package/src/data/valve/graph.ts +77 -0
- package/src/data/valve/index.ts +53 -0
- package/src/data/valve/namespace.ts +57 -0
- package/src/data/valve/registry.ts +50 -0
- package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
- package/src/data/valve/schema/valve-crossing.ts +9 -0
- package/src/data/valve/supply.ts +154 -0
- package/src/data/valve/types.ts +143 -0
- package/src/data/vector/columns.ts +27 -0
- package/src/data/vector/index.ts +4 -0
- package/src/data/vector/namespace.ts +33 -0
- package/src/data/vector/query.ts +85 -0
- package/src/data/vector/validate.ts +16 -0
- package/src/data/write/audit/actor.ts +15 -0
- package/src/data/write/audit/audit-columns.ts +18 -0
- package/src/data/write/audit/audit-select.ts +111 -0
- package/src/data/write/audit/bound-transaction.ts +14 -0
- package/src/data/write/audit/cte-compile.ts +32 -0
- package/src/data/write/audit/decode-returning.ts +47 -0
- package/src/data/write/audit/final-select.ts +43 -0
- package/src/data/write/audit/infra-audit.ts +36 -0
- package/src/data/write/audit/primary-key.ts +65 -0
- package/src/data/write/audit/projection.ts +41 -0
- package/src/data/write/audit/read-touches.ts +86 -0
- package/src/data/write/audit/touch-match.ts +78 -0
- package/src/data/write/audit/verb-delete.ts +62 -0
- package/src/data/write/audit/verb-insert.ts +36 -0
- package/src/data/write/audit/verb-select.ts +47 -0
- package/src/data/write/audit/verb-update.ts +60 -0
- package/src/data/write/audit/verb-upsert.ts +104 -0
- package/src/data/write/conflict.ts +43 -0
- package/src/data/write/delete.ts +38 -0
- package/src/data/write/execute.ts +243 -0
- package/src/data/write/index.ts +11 -0
- package/src/data/write/insert.ts +63 -0
- package/src/data/write/state.ts +43 -0
- package/src/data/write/update.ts +54 -0
- package/src/data/write/upsert.ts +58 -0
- package/src/data/write/values.ts +19 -0
- package/src/index.ts +230 -0
- package/src/infra/access-links/code.ts +70 -0
- package/src/infra/access-links/handle.ts +892 -0
- package/src/infra/access-links/index.ts +9 -0
- package/src/infra/access-links/namespace.ts +30 -0
- package/src/infra/access-links/person-check.ts +177 -0
- package/src/infra/access-links/schema.ts +71 -0
- package/src/infra/alerts/async-sources.ts +231 -0
- package/src/infra/alerts/audit.ts +36 -0
- package/src/infra/alerts/beat.ts +123 -0
- package/src/infra/alerts/client-faults.ts +111 -0
- package/src/infra/alerts/faults.ts +156 -0
- package/src/infra/alerts/handle.ts +778 -0
- package/src/infra/alerts/http.ts +192 -0
- package/src/infra/alerts/index.ts +12 -0
- package/src/infra/alerts/infra-sources.ts +180 -0
- package/src/infra/alerts/namespace.ts +81 -0
- package/src/infra/alerts/redact.ts +21 -0
- package/src/infra/alerts/request-faults.ts +41 -0
- package/src/infra/alerts/schema.ts +124 -0
- package/src/infra/alerts/sinks.ts +125 -0
- package/src/infra/alerts/source-options.ts +26 -0
- package/src/infra/alerts/sources.ts +503 -0
- package/src/infra/alerts/subsystem-sources.ts +137 -0
- package/src/infra/audit/emit.ts +87 -0
- package/src/infra/audit/index.ts +2 -0
- package/src/infra/audit/namespace.ts +22 -0
- package/src/infra/audit/schema.sql +35 -0
- package/src/infra/audit/types.ts +18 -0
- package/src/infra/aws/credentials.ts +147 -0
- package/src/infra/aws/sigv4.ts +140 -0
- package/src/infra/cache/create.ts +58 -0
- package/src/infra/cache/entry.ts +81 -0
- package/src/infra/cache/index.ts +4 -0
- package/src/infra/cache/namespace.ts +10 -0
- package/src/infra/cache/tenant.ts +93 -0
- package/src/infra/cache/types.ts +24 -0
- package/src/infra/crawl/audit.ts +35 -0
- package/src/infra/crawl/errors.ts +25 -0
- package/src/infra/crawl/handle.ts +693 -0
- package/src/infra/crawl/index.ts +9 -0
- package/src/infra/crawl/links.ts +160 -0
- package/src/infra/crawl/namespace.ts +36 -0
- package/src/infra/crawl/robots.ts +100 -0
- package/src/infra/crawl/schema.ts +89 -0
- package/src/infra/credit-line/handle.ts +883 -0
- package/src/infra/credit-line/index.ts +6 -0
- package/src/infra/credit-line/namespace.ts +7 -0
- package/src/infra/credit-line/schema.ts +96 -0
- package/src/infra/credits/handle.ts +706 -0
- package/src/infra/credits/index.ts +5 -0
- package/src/infra/credits/namespace.ts +7 -0
- package/src/infra/credits/schema.ts +83 -0
- package/src/infra/email/audit.ts +36 -0
- package/src/infra/email/inbound.ts +622 -0
- package/src/infra/email/index.ts +11 -0
- package/src/infra/email/mail.ts +517 -0
- package/src/infra/email/mime.ts +447 -0
- package/src/infra/email/namespace.ts +22 -0
- package/src/infra/email/providers.ts +111 -0
- package/src/infra/email/schema.ts +96 -0
- package/src/infra/email/sns.ts +200 -0
- package/src/infra/email/unsubscribe.ts +44 -0
- package/src/infra/fetch/address.ts +88 -0
- package/src/infra/fetch/connect-retry.ts +87 -0
- package/src/infra/fetch/handle.ts +329 -0
- package/src/infra/fetch/index.ts +7 -0
- package/src/infra/fetch/namespace.ts +70 -0
- package/src/infra/idempotency/index.ts +3 -0
- package/src/infra/idempotency/middleware.ts +87 -0
- package/src/infra/idempotency/store.ts +92 -0
- package/src/infra/idempotency/types.ts +30 -0
- package/src/infra/join-requests/handle.ts +312 -0
- package/src/infra/join-requests/index.ts +2 -0
- package/src/infra/join-requests/namespace.ts +7 -0
- package/src/infra/join-requests/schema.ts +33 -0
- package/src/infra/outbox/core.ts +165 -0
- package/src/infra/quota/errors.ts +32 -0
- package/src/infra/quota/handle.ts +215 -0
- package/src/infra/quota/index.ts +5 -0
- package/src/infra/quota/namespace.ts +7 -0
- package/src/infra/quota/schema.ts +15 -0
- package/src/infra/storage/backend.ts +34 -0
- package/src/infra/storage/handle.ts +513 -0
- package/src/infra/storage/index.ts +9 -0
- package/src/infra/storage/local.ts +104 -0
- package/src/infra/storage/namespace.ts +13 -0
- package/src/infra/storage/s3.ts +257 -0
- package/src/infra/storage/schema.ts +24 -0
- package/src/infra/support/handle.ts +854 -0
- package/src/infra/support/http.ts +229 -0
- package/src/infra/support/index.ts +7 -0
- package/src/infra/support/namespace.ts +12 -0
- package/src/infra/support/schema.ts +96 -0
- package/src/infra/webhook/inbound.ts +45 -0
- package/src/infra/webhook/index.ts +5 -0
- package/src/infra/webhook/namespace.ts +24 -0
- package/src/infra/webhook/outbound.ts +315 -0
- package/src/infra/webhook/sign.ts +32 -0
- package/src/infra/webhook/verify.ts +82 -0
- package/src/invariants/assert.ts +26 -0
- package/src/invariants/index.ts +1 -0
- package/src/lint/config.ts +56 -0
- package/src/lint/doctrine.ts +163 -0
- package/src/lint/include.ts +97 -0
- package/src/lint/index.ts +3 -0
- package/src/lint/preset.ts +10 -0
- package/src/lint/resolver.ts +218 -0
- package/src/lint/rules/no-reexports.ts +46 -0
- package/src/request/behavior/audited.ts +85 -0
- package/src/request/behavior/cached.ts +62 -0
- package/src/request/behavior/compose.ts +28 -0
- package/src/request/behavior/index.ts +5 -0
- package/src/request/behavior/namespace.ts +16 -0
- package/src/request/behavior/types.ts +26 -0
- package/src/request/behavior/versioned.ts +54 -0
- package/src/request/context/create.ts +103 -0
- package/src/request/context/guard.ts +49 -0
- package/src/request/context/index.ts +4 -0
- package/src/request/context/run-in-transaction.ts +200 -0
- package/src/request/context/store.ts +28 -0
- package/src/request/context/types.ts +50 -0
- package/src/request/di/builder.ts +535 -0
- package/src/request/di/index.ts +8 -0
- package/src/request/di/resolve.ts +271 -0
- package/src/request/di/types.ts +262 -0
- package/src/request/fitting/namespace.ts +7 -0
- package/src/request/fixture/batch.ts +71 -0
- package/src/request/fixture/builder.ts +26 -0
- package/src/request/fixture/errors.ts +10 -0
- package/src/request/fixture/index.ts +8 -0
- package/src/request/fixture/namespace.ts +23 -0
- package/src/request/fixture/paginate.ts +44 -0
- package/src/request/fixture/to-handlers.ts +260 -0
- package/src/request/fixture/types.ts +79 -0
- package/src/request/flow/act-as.ts +78 -0
- package/src/request/flow/namespace.ts +17 -0
- package/src/request/http/error-step.ts +42 -0
- package/src/request/http/file.ts +141 -0
- package/src/request/http/index.ts +4 -0
- package/src/request/http/middleware.ts +141 -0
- package/src/request/http/namespace.ts +5 -0
- package/src/request/http/rate-limit-classes.ts +170 -0
- package/src/request/http/response.ts +82 -0
- package/src/request/http/server.ts +916 -0
- package/src/request/http/sniff.ts +261 -0
- package/src/request/http/types.ts +250 -0
- package/src/request/http/upload.ts +284 -0
- package/src/request/internal/text.ts +6 -0
- package/src/request/openapi/generate.ts +205 -0
- package/src/request/openapi/index.ts +2 -0
- package/src/request/openapi/namespace.ts +7 -0
- package/src/test/auth.ts +13 -0
- package/src/test/auto-setup.ts +45 -0
- package/src/test/context.ts +91 -0
- package/src/test/crawl-fixture.ts +157 -0
- package/src/test/discover-fork.ts +17 -0
- package/src/test/fingerprint.ts +135 -0
- package/src/test/globals.d.ts +7 -0
- package/src/test/index.ts +19 -0
- package/src/test/namespace.ts +87 -0
- package/src/test/plugin.ts +189 -0
- package/src/test/progress.ts +185 -0
- package/src/test/reap-after-run.ts +28 -0
- package/src/test/reap.ts +286 -0
- package/src/test/reset.ts +253 -0
- package/src/test/savepoints.ts +172 -0
- package/src/test/setup.ts +1422 -0
- package/src/test/system-channel.ts +50 -0
- package/src/test/vitest-plugin.ts +142 -0
- package/src/test/vitest-workspace.ts +507 -0
- package/src/test/vitest.ts +333 -0
- package/src/test/watch.ts +105 -0
- package/src/trace/capture/bounded-serializer.ts +146 -0
- package/src/trace/cardinality/analyze.ts +173 -0
- package/src/trace/cardinality/branch-context.ts +214 -0
- package/src/trace/cardinality/check.ts +33 -0
- package/src/trace/cardinality/classify-edge.ts +64 -0
- package/src/trace/cardinality/structural-max.ts +107 -0
- package/src/trace/check/coverage-check.ts +188 -0
- package/src/trace/check/flow-check.ts +103 -0
- package/src/trace/check/flow-step-site.ts +234 -0
- package/src/trace/check/io-pattern.ts +116 -0
- package/src/trace/codemod/add-cardinality.ts +152 -0
- package/src/trace/codemod/run.ts +80 -0
- package/src/trace/entry/child-trace.ts +21 -0
- package/src/trace/entry/close-trace.ts +87 -0
- package/src/trace/entry/finalize-trace.ts +28 -0
- package/src/trace/entry/open-trace.ts +138 -0
- package/src/trace/entry/parent-trace.ts +20 -0
- package/src/trace/entry/parse-traceparent.ts +39 -0
- package/src/trace/entry/process-start.ts +38 -0
- package/src/trace/entry/trace-context.ts +26 -0
- package/src/trace/entry/tracing-enabled.ts +10 -0
- package/src/trace/external/record.ts +195 -0
- package/src/trace/external/report.ts +66 -0
- package/src/trace/namespace.ts +19 -0
- package/src/trace/partition/day.ts +47 -0
- package/src/trace/partition/index.ts +14 -0
- package/src/trace/partition/partition-name.ts +23 -0
- package/src/trace/partition/plan.ts +82 -0
- package/src/trace/partition/schedule.ts +26 -0
- package/src/trace/partition/startup-hook.ts +39 -0
- package/src/trace/partition/sync.ts +154 -0
- package/src/trace/partition/tables.ts +53 -0
- package/src/trace/read/tree.ts +83 -0
- package/src/trace/retain/copy-on-error.ts +76 -0
- package/src/trace/runtime/expire-stale-traces.ts +54 -0
- package/src/trace/runtime/flow-step.ts +147 -0
- package/src/trace/runtime/flush-steps.ts +31 -0
- package/src/trace/runtime/pending-writes.ts +54 -0
- package/src/trace/runtime/step-buffer.ts +298 -0
- package/src/trace/runtime/step-error.ts +21 -0
- package/src/trace/runtime/step-meta.ts +55 -0
- package/src/trace/runtime/thenable.ts +12 -0
- package/src/trace/runtime/truncate-trace.ts +62 -0
- package/src/trace/schema/trace-fields.ts +52 -0
- package/src/trace/schema/trace-retained.ts +22 -0
- package/src/trace/schema/trace-step-fields.ts +44 -0
- package/src/trace/schema/trace-step-retained.ts +25 -0
- package/src/trace/schema/trace-step.ts +18 -0
- package/src/trace/schema/trace.ts +12 -0
- package/src/trace/transformer/apply.ts +79 -0
- package/src/trace/transformer/build-meta-literal.ts +30 -0
- package/src/trace/transformer/find-flow-step-sites.ts +85 -0
- package/src/trace/transformer/inject-flow-step-import.ts +39 -0
- package/src/trace/transformer/transform-error.ts +14 -0
- package/src/trace/transformer/transform-flow-steps.ts +123 -0
- package/src/trace/transformer/wrap-function-declaration.ts +62 -0
- package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
- package/src/workspace/affected.ts +110 -0
- package/src/workspace/changed-files.ts +163 -0
- package/src/workspace/check-deps.ts +213 -0
- package/src/workspace/checksum.ts +20 -0
- package/src/workspace/dependency-graph.ts +67 -0
- package/src/workspace/detect-pipework-copies.ts +93 -0
- package/src/workspace/discover.ts +206 -0
- package/src/workspace/generate.ts +401 -0
- package/src/workspace/index.ts +7 -0
- package/src/workspace/module-imports-pipework.ts +39 -0
- package/src/workspace/module-package-name.ts +12 -0
- package/src/workspace/scrub-git-env.ts +23 -0
- package/src/workspace/source-paths.ts +36 -0
- package/src/workspace/sync.ts +182 -0
- package/dist/REFERENCE.md +0 -1477
- package/dist/infra/access-links/rate-limit.d.ts +0 -10
- package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
- package/dist/infra/access-links/rate-limit.js +0 -28
- package/dist/infra/access-links/rate-limit.js.map +0 -1
- package/dist/infra/email/sigv4.d.ts +0 -24
- package/dist/infra/email/sigv4.d.ts.map +0 -1
- package/dist/infra/email/sigv4.js +0 -55
- package/dist/infra/email/sigv4.js.map +0 -1
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
import { sql } from '../../data/db/sql.js'
|
|
2
|
+
import { pipe, pipeSystem } from '../../data/db/db.js'
|
|
3
|
+
import type { DB } from '../../data/db/index.js'
|
|
4
|
+
import { fitting } from '../../request/di/index.js'
|
|
5
|
+
import { requireContext, getContext } from '../../request/context/store.js'
|
|
6
|
+
import type { Flow } from '../../request/context/types.js'
|
|
7
|
+
import { inTenant } from '../../auth/tenant/in-tenant.js'
|
|
8
|
+
import { resolveActorId } from '../../data/write/audit/actor.js'
|
|
9
|
+
import { getBaseLogger } from '../../core/logging/index.js'
|
|
10
|
+
import { recordFault, faultMessage } from '../alerts/faults.js'
|
|
11
|
+
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '../../core/errors/index.js'
|
|
12
|
+
import { defineStateMachine } from '../../async/state-machine/define.js'
|
|
13
|
+
import type { Queue } from '../../async/jobs/queue.js'
|
|
14
|
+
import type { Handler } from '../../request/di/types.js'
|
|
15
|
+
import { __flowStep } from '../../trace/runtime/flow-step.js'
|
|
16
|
+
import { recordBeat, beatsEnabled } from '../alerts/beat.js'
|
|
17
|
+
import type { Mail } from '../email/mail.js'
|
|
18
|
+
import type { ReceivedEmail } from '../email/inbound.js'
|
|
19
|
+
import { createOutbox, type Sink, type SinkDelivery } from '../outbox/core.js'
|
|
20
|
+
import { TICKET_STATES } from './schema.js'
|
|
21
|
+
import { describeError } from '../../core/logging/error-chain.js'
|
|
22
|
+
|
|
23
|
+
export type TicketState = typeof TICKET_STATES[number]
|
|
24
|
+
export type SupportEventKind = 'ticket.created' | 'ticket.customer-replied' | 'draft.awaiting-approval'
|
|
25
|
+
|
|
26
|
+
/** What a sink receives as `delivery.payload` for a support event. No message bodies. */
|
|
27
|
+
export interface SupportEventPayload {
|
|
28
|
+
readonly kind: SupportEventKind
|
|
29
|
+
readonly ticketId: string
|
|
30
|
+
readonly tenantId: string
|
|
31
|
+
readonly subject: string
|
|
32
|
+
readonly state: TicketState
|
|
33
|
+
readonly category: string | null
|
|
34
|
+
readonly at: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A place ticket events are sent: `send(delivery)` gets `delivery.payload` as a `SupportEventPayload`. `alerts.webhook()` and `alerts.memory()` fit where a `SupportSink` is expected. */
|
|
38
|
+
export type SupportSink = Sink<SupportEventPayload>
|
|
39
|
+
export type SupportDelivery = SinkDelivery<SupportEventPayload>
|
|
40
|
+
|
|
41
|
+
/** What `screen` decides for a received email: `accept` it, `separate` it (open a new ticket, never add to an existing one), or `drop` it. */
|
|
42
|
+
export type ScreenVerdict = 'accept' | 'separate' | 'drop'
|
|
43
|
+
|
|
44
|
+
export interface SupportConfig {
|
|
45
|
+
/** The app's mail interface; the only caller of its `send` is `approve`. Its templates must include `support.templates`. */
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the app's own template map
|
|
47
|
+
readonly mail: Pick<Mail<any>, 'send'>
|
|
48
|
+
/**
|
|
49
|
+
* Where ticket events go: `alerts.webhook(...)`, `alerts.memory()` or your own `SupportSink`. A delivery's
|
|
50
|
+
* `kind` is the event kind, its `key` the ticket id and its `payload` a `SupportEventPayload`.
|
|
51
|
+
*/
|
|
52
|
+
readonly sinks: readonly SupportSink[]
|
|
53
|
+
/** The queue the events job runs on; its worker surface must run `support.job`. */
|
|
54
|
+
readonly queue: Pick<Queue, 'enqueue'>
|
|
55
|
+
/**
|
|
56
|
+
* Whether `actorId` may approve replies for `tenantId`. Called by `approve` before anything is read or sent.
|
|
57
|
+
* With rbac: `({ actorId, tenantId }) => rbac.can(actorId, 'support:approve', { tenantId })`.
|
|
58
|
+
*/
|
|
59
|
+
readonly canApprove: (who: { actorId: string; tenantId: string }) => Promise<boolean>
|
|
60
|
+
/** The author of a draft may not approve it. Default true. */
|
|
61
|
+
readonly distinctApprover?: boolean
|
|
62
|
+
/** Template name `approve` sends with; default `'pipework.support.reply'`. The app's mail templates must define it (spread `support.templates`). */
|
|
63
|
+
readonly replyTemplate?: string
|
|
64
|
+
/**
|
|
65
|
+
* Decides what `receiveEmail` does with a message. Default: virus verdict `FAIL` drops it; DMARC `FAIL`, or SPF and
|
|
66
|
+
* DKIM both `FAIL`, separates it (the From address is probably forged, so it must not add text to a ticket an agent
|
|
67
|
+
* is reading: it always opens a new ticket); anything else, including no verdicts (the folder stand-in), is accepted.
|
|
68
|
+
*/
|
|
69
|
+
readonly screen?: (email: ReceivedEmail) => ScreenVerdict
|
|
70
|
+
/**
|
|
71
|
+
* Finds the user a sender is: `address` is lowercased. Called inside the receive's tenant transaction (`tx` is bound
|
|
72
|
+
* to that tenant; a throw rolls the receive back) when a message has no user of its own: for a new ticket, and for a
|
|
73
|
+
* message joining a ticket whose `user_id` is null, which it fills. A ticket's non-null `user_id` is never changed.
|
|
74
|
+
* `receive` with `contact.userId` does not call it. Null: no known user.
|
|
75
|
+
*/
|
|
76
|
+
readonly resolveContact?: (contact: { address: string; name: string | null; tenantId: string }, tx: DB) => Promise<{ userId: string } | null>
|
|
77
|
+
/**
|
|
78
|
+
* Who may join a ticket by thread. `'contact'` (default): only the ticket's contact; a message from anyone else in the
|
|
79
|
+
* thread opens its own ticket, and `receive` with a `ticketId` refuses a sender who is not the contact. `'conversation'`:
|
|
80
|
+
* a message threaded into the ticket's conversation, or sent to it with `ticketId`, joins it and reopens it whoever sent
|
|
81
|
+
* it; the message keeps its own sender, and the ticket's contact and user do not change. Risk: with `'conversation'`,
|
|
82
|
+
* anyone who holds a reply's Message-ID can add to the ticket, so pair it with a strict `screen` (for example, require
|
|
83
|
+
* a DMARC pass). A message `screen` separates still never joins.
|
|
84
|
+
*/
|
|
85
|
+
readonly joinThread?: 'contact' | 'conversation'
|
|
86
|
+
/** Job type of `support.job`; default `'pipework.support.events'`. */
|
|
87
|
+
readonly jobType?: string
|
|
88
|
+
/** Delivery attempts before an event is given up on; default 10. */
|
|
89
|
+
readonly maxAttempts?: number
|
|
90
|
+
/** First retry delay, doubling per attempt up to one hour; default 5000. */
|
|
91
|
+
readonly backoffMs?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Ticket {
|
|
95
|
+
readonly id: string
|
|
96
|
+
readonly tenantId: string
|
|
97
|
+
readonly state: TicketState
|
|
98
|
+
readonly category: string | null
|
|
99
|
+
readonly subject: string
|
|
100
|
+
readonly contactEmail: string
|
|
101
|
+
readonly contactName: string | null
|
|
102
|
+
readonly userId: string | null
|
|
103
|
+
/** The email conversation this ticket answers into, or `null` (chat, API, or a second sender in a conversation another ticket holds). */
|
|
104
|
+
readonly conversationId: string | null
|
|
105
|
+
readonly createdAt: Date
|
|
106
|
+
readonly updatedAt: Date
|
|
107
|
+
readonly lastCustomerMessageAt: Date
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface TicketMessage {
|
|
111
|
+
readonly id: string
|
|
112
|
+
readonly ticketId: string
|
|
113
|
+
readonly kind: 'inbound' | 'note' | 'draft' | 'outbound'
|
|
114
|
+
readonly channel: 'email' | 'chat' | 'api'
|
|
115
|
+
readonly subject: string | null
|
|
116
|
+
readonly text: string
|
|
117
|
+
readonly html: string | null
|
|
118
|
+
readonly fromAddress: string | null
|
|
119
|
+
readonly inboundEmailId: string | null
|
|
120
|
+
/** Stored file ids and names of an inbound email's attachments. */
|
|
121
|
+
readonly attachments: readonly { id: string; filename: string | null; mediaType: string; size: number }[] | null
|
|
122
|
+
readonly draftStatus: 'draft' | 'approved' | 'discarded' | null
|
|
123
|
+
/** Starts at 1; `editDraft` adds one. `approve` must be given the version the approver read. */
|
|
124
|
+
readonly version: number
|
|
125
|
+
readonly approvedBy: string | null
|
|
126
|
+
readonly approvedAt: Date | null
|
|
127
|
+
/** The `pipework_email_messages` row an approved draft (and its outbound message) was sent as. */
|
|
128
|
+
readonly emailMessageId: string | null
|
|
129
|
+
readonly createdBy: string | null
|
|
130
|
+
readonly createdAt: Date
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface TicketLink {
|
|
134
|
+
readonly id: string
|
|
135
|
+
readonly ticketId: string
|
|
136
|
+
readonly kind: string
|
|
137
|
+
readonly ref: string
|
|
138
|
+
readonly label: string | null
|
|
139
|
+
readonly createdAt: Date
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface TicketDetail {
|
|
143
|
+
readonly ticket: Ticket
|
|
144
|
+
/** Oldest first. */
|
|
145
|
+
readonly messages: readonly TicketMessage[]
|
|
146
|
+
readonly links: readonly TicketLink[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ListOptions {
|
|
150
|
+
readonly state?: TicketState
|
|
151
|
+
readonly category?: string
|
|
152
|
+
readonly limit?: number
|
|
153
|
+
/** The `next` of a previous page. */
|
|
154
|
+
readonly cursor?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ListPage {
|
|
158
|
+
/** Newest first. */
|
|
159
|
+
readonly tickets: readonly Ticket[]
|
|
160
|
+
readonly next: string | null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DraftInput {
|
|
164
|
+
readonly text: string
|
|
165
|
+
readonly html?: string
|
|
166
|
+
/** Default `Re: <ticket subject>`. */
|
|
167
|
+
readonly subject?: string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type ReceiveResult =
|
|
171
|
+
| { readonly status: 'received' | 'duplicate'; readonly ticketId: string; readonly created: boolean }
|
|
172
|
+
| { readonly status: 'dropped' | 'no-tenant' }
|
|
173
|
+
|
|
174
|
+
export interface ReceiveInput {
|
|
175
|
+
readonly channel: 'chat' | 'api'
|
|
176
|
+
readonly contact: { readonly email: string; readonly name?: string; readonly userId?: string }
|
|
177
|
+
readonly text: string
|
|
178
|
+
readonly html?: string
|
|
179
|
+
/** Subject of a new ticket; default the first line of `text`. */
|
|
180
|
+
readonly subject?: string
|
|
181
|
+
/** Adds to this ticket (the contact must be the ticket's) instead of opening one. */
|
|
182
|
+
readonly ticketId?: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A draft could not be approved, edited or discarded: it is already approved, discarded, or changed since `expectedVersion`. */
|
|
186
|
+
export class DraftStateError extends ConflictError {
|
|
187
|
+
constructor(readonly reason: 'approved' | 'discarded' | 'changed' | 'not-awaiting-approval', message: string) {
|
|
188
|
+
super(message, { reason })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The calls on one tenant's tickets. Each also exists as `<name>For(tenantId, ...)` for callers with no tenant in the Flow. */
|
|
193
|
+
export interface TicketApi {
|
|
194
|
+
get(ticketId: string): Promise<TicketDetail>
|
|
195
|
+
list(options?: ListOptions): Promise<ListPage>
|
|
196
|
+
/** `null` clears it. */
|
|
197
|
+
setCategory(ticketId: string, category: string | null): Promise<Ticket>
|
|
198
|
+
/** Moves the ticket along the state machine; an illegal move throws `InvalidTransitionError`. `awaiting_approval` and `answered` are reached only by drafting and approving. */
|
|
199
|
+
transition(ticketId: string, to: TicketState): Promise<Ticket>
|
|
200
|
+
/** An internal note: stored, audited, never sent. */
|
|
201
|
+
addNote(ticketId: string, text: string): Promise<TicketMessage>
|
|
202
|
+
link(ticketId: string, link: { kind: string; ref: string; label?: string }): Promise<TicketLink>
|
|
203
|
+
/**
|
|
204
|
+
* Stores a draft reply and moves the ticket to `awaiting_approval` (from `open` through `triaged`).
|
|
205
|
+
* A ticket has one live draft: a new draft discards the previous. Nothing is sent.
|
|
206
|
+
*/
|
|
207
|
+
draftReply(ticketId: string, draft: DraftInput): Promise<{ draftId: string; version: number }>
|
|
208
|
+
/** Changes the live draft's content and adds one to its version; the editor becomes its author for `distinctApprover`. */
|
|
209
|
+
editDraft(draftId: string, draft: Partial<DraftInput>): Promise<{ draftId: string; version: number }>
|
|
210
|
+
/** Discards the live draft; the ticket goes back to `triaged`. */
|
|
211
|
+
discardDraft(draftId: string): Promise<void>
|
|
212
|
+
/**
|
|
213
|
+
* Approves and sends a draft. The actor is the Flow's `auth.userId`. Refused (`ForbiddenError`) when `canApprove`
|
|
214
|
+
* says no or, with `distinctApprover`, when the actor wrote the draft. Refused with `DraftStateError` when the draft
|
|
215
|
+
* is approved, discarded or at another version than `expectedVersion`. In one transaction the draft is marked
|
|
216
|
+
* approved (approver and time recorded), exactly its text and html are handed to `mail.send` as a reply in the
|
|
217
|
+
* ticket's conversation, an outbound message is stored and the ticket becomes `answered`.
|
|
218
|
+
*/
|
|
219
|
+
approve(draftId: string, options: { expectedVersion: number }): Promise<{ messageId: string; emailMessageId: string }>
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type WithFor<T> = T & {
|
|
223
|
+
[K in keyof T & string as `${K}For`]: T[K] extends (...args: infer A) => infer R ? (tenantId: string, ...args: A) => R : never
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface Support extends WithFor<TicketApi> {
|
|
227
|
+
/**
|
|
228
|
+
* Hands a received email to the ticket system; call it from `mail.inbound`'s `onMessage`. It uses `email.tenantId`
|
|
229
|
+
* (none: logged, `{ status: 'no-tenant' }`) and is idempotent on `email.id`. A message `screen` drops (default: virus
|
|
230
|
+
* verdict `FAIL`) opens nothing and is audited; one it separates (default: DMARC `FAIL`, or SPF and DKIM both `FAIL`)
|
|
231
|
+
* always opens a new ticket and never adds to an existing one, because a forged From address must not add text to a
|
|
232
|
+
* ticket the agent is reading. A new conversation opens a ticket; a message in a conversation that
|
|
233
|
+
* has a ticket joins it only when its sender is the ticket's contact (threading is not authentication), and
|
|
234
|
+
* otherwise opens a new ticket, tied to the conversation if no ticket holds it yet. A customer message moves the
|
|
235
|
+
* ticket to `open` and discards a live draft.
|
|
236
|
+
*/
|
|
237
|
+
receiveEmail(email: ReceivedEmail): Promise<ReceiveResult>
|
|
238
|
+
/** A customer message from another channel; same rules as `receiveEmail`. */
|
|
239
|
+
receive(tenantId: string, input: ReceiveInput): Promise<ReceiveResult>
|
|
240
|
+
/**
|
|
241
|
+
* Tickets of every tenant (or of `tenantId`), newest first, in the shape and cursor of `list`. Needs a Flow but no tenant.
|
|
242
|
+
* It reads on the system channel, which a database with `rls.platformRole` refuses over these tenant-scoped tables.
|
|
243
|
+
*/
|
|
244
|
+
listAll(options?: ListOptions & { tenantId?: string }): Promise<ListPage>
|
|
245
|
+
/** The job to add to a worker surface's `jobs`: delivers ticket events to the sinks. */
|
|
246
|
+
readonly job: Handler<Record<string, unknown>, unknown>
|
|
247
|
+
/** What one run of `job` does; callable from a test. Needs a Flow. */
|
|
248
|
+
tick(): Promise<{ delivered: number; retried: number; failed: number }>
|
|
249
|
+
/**
|
|
250
|
+
* Puts the event deliveries that were given up (and never delivered) back on the queue, for every tenant, optionally
|
|
251
|
+
* only those of `sink` and/or that failed at or after `failedSince`; the next `tick` sends them. Delivery ids stay the
|
|
252
|
+
* same, so a receiver that dedupes on `delivery.id` takes each once. Writes a `requeue` audit row per delivery naming `by`
|
|
253
|
+
* (who asked: an operator or key name, required). Needs a Flow but no tenant.
|
|
254
|
+
*/
|
|
255
|
+
requeueFailed(options: { by: string; sink?: string; failedSince?: Date }): Promise<{ requeued: number }>
|
|
256
|
+
/** Event deliveries, oldest first, for a ticket or all. */
|
|
257
|
+
deliveries(options?: { ticketId?: string }): Promise<readonly { id: string; ticketId: string; kind: SupportEventKind; sink: string; attempts: number; deliveredAt: Date | null; failedAt: Date | null; lastError: string | null }[]>
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
261
|
+
const DEFAULT_LIST_LIMIT = 50
|
|
262
|
+
const MAX_LIST_LIMIT = 200
|
|
263
|
+
const ENTITY = 'SupportTicket'
|
|
264
|
+
|
|
265
|
+
function toDate(value: unknown): Date {
|
|
266
|
+
if (value instanceof Date) return value
|
|
267
|
+
const text = String(value)
|
|
268
|
+
return new Date(/(Z|[+-]\d\d(:?\d\d)?)$/.test(text) ? text : `${text.replace(' ', 'T')}Z`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function toTicket(r: Record<string, unknown>): Ticket {
|
|
272
|
+
return {
|
|
273
|
+
id: r['id'] as string, tenantId: r['tenant_id'] as string, state: r['state'] as TicketState,
|
|
274
|
+
category: (r['category'] ?? null) as string | null, subject: r['subject'] as string,
|
|
275
|
+
contactEmail: r['contact_email'] as string, contactName: (r['contact_name'] ?? null) as string | null,
|
|
276
|
+
userId: (r['user_id'] ?? null) as string | null, conversationId: (r['conversation_id'] ?? null) as string | null,
|
|
277
|
+
createdAt: toDate(r['created_at']), updatedAt: toDate(r['updated_at']), lastCustomerMessageAt: toDate(r['last_customer_message_at']),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function toMessage(r: Record<string, unknown>): TicketMessage {
|
|
282
|
+
return {
|
|
283
|
+
id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as TicketMessage['kind'],
|
|
284
|
+
channel: r['channel'] as TicketMessage['channel'], subject: (r['subject'] ?? null) as string | null,
|
|
285
|
+
text: r['text'] as string, html: (r['html'] ?? null) as string | null, fromAddress: (r['from_address'] ?? null) as string | null,
|
|
286
|
+
inboundEmailId: (r['inbound_email_id'] ?? null) as string | null,
|
|
287
|
+
attachments: (r['attachments'] ?? null) as TicketMessage['attachments'],
|
|
288
|
+
draftStatus: (r['draft_status'] ?? null) as TicketMessage['draftStatus'], version: Number(r['version']),
|
|
289
|
+
approvedBy: (r['approved_by'] ?? null) as string | null,
|
|
290
|
+
approvedAt: r['approved_at'] === null || r['approved_at'] === undefined ? null : toDate(r['approved_at']),
|
|
291
|
+
emailMessageId: (r['email_message_id'] ?? null) as string | null, createdBy: (r['created_by'] ?? null) as string | null,
|
|
292
|
+
createdAt: toDate(r['created_at']),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function toLink(r: Record<string, unknown>): TicketLink {
|
|
297
|
+
return {
|
|
298
|
+
id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as string, ref: r['ref'] as string,
|
|
299
|
+
label: (r['label'] ?? null) as string | null, createdAt: toDate(r['created_at']),
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function nonEmpty(field: string, value: unknown): string {
|
|
304
|
+
if (typeof value !== 'string' || value.trim() === '') throw new ValidationError({ [field]: ['must be a non-empty string'] }, `Support ${field} must be a non-empty string.`)
|
|
305
|
+
return value
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function requireUuid(field: string, value: unknown): string {
|
|
309
|
+
if (typeof value !== 'string' || !UUID_RE.test(value)) throw new ValidationError({ [field]: ['must be a UUID'] }, `Support ${field} must be a UUID.`)
|
|
310
|
+
return value
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Mail templates for `mail.create({ templates: { ...support.templates, ... } })`: the reply is rendered verbatim from the approved draft, nothing added. */
|
|
314
|
+
export const supportTemplates = {
|
|
315
|
+
'pipework.support.reply': ({ subject, text, html }: { subject: string; text: string; html?: string | null }): { subject: string; text: string; html?: string } =>
|
|
316
|
+
({ subject, text, ...(html !== undefined && html !== null ? { html } : {}) }),
|
|
317
|
+
} as const
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Creates the support handle: tickets for a tenant's customers, opened from received email, with replies
|
|
321
|
+
* sent only after an approval.
|
|
322
|
+
*
|
|
323
|
+
* Needs blueprint `support: {}` (tables `support_ticket`, `support_message`, `support_link` with forced
|
|
324
|
+
* row-level security, and the event outbox `support_event_delivery`) and, for email, `email: {}`.
|
|
325
|
+
* The app's mail templates must include `support.templates`, which render an approved draft verbatim.
|
|
326
|
+
*
|
|
327
|
+
* The only code path that sends a reply is `approve`: it needs an actor that `canApprove` accepts (and, by
|
|
328
|
+
* default, who is not the draft's author), and hands `mail.send` exactly the approved text and html in the
|
|
329
|
+
* ticket's conversation. Events (`ticket.created`, `ticket.customer-replied`, `draft.awaiting-approval`)
|
|
330
|
+
* go to the sinks through an outbox with retries, delivered by `job` or `tick()`. Every change is a
|
|
331
|
+
* `SupportTicket:<verb>` audit record under the Flow's open trace step.
|
|
332
|
+
*/
|
|
333
|
+
export function createSupport(config: SupportConfig): Support {
|
|
334
|
+
if (config.sinks.length === 0) throw new Error('[pipework] support.create needs at least one sink.')
|
|
335
|
+
const sinks = new Map<string, SupportSink>()
|
|
336
|
+
for (const s of config.sinks) {
|
|
337
|
+
if (sinks.has(s.name)) throw new Error(`[pipework] support.create: two sinks are named "${s.name}"; give each its own name.`)
|
|
338
|
+
sinks.set(s.name, s)
|
|
339
|
+
}
|
|
340
|
+
const sinkNames = JSON.stringify([...sinks.keys()])
|
|
341
|
+
const joinThread = config.joinThread ?? 'contact'
|
|
342
|
+
if ((joinThread as string) !== 'contact' && (joinThread as string) !== 'conversation') {
|
|
343
|
+
throw new Error(`[pipework] support.create: joinThread must be 'contact' or 'conversation', got ${JSON.stringify(joinThread)}.`)
|
|
344
|
+
}
|
|
345
|
+
const jobType = config.jobType ?? 'pipework.support.events'
|
|
346
|
+
const replyTemplate = config.replyTemplate ?? 'pipework.support.reply'
|
|
347
|
+
const distinctApprover = config.distinctApprover ?? true
|
|
348
|
+
const screen = config.screen ?? ((email: ReceivedEmail): ScreenVerdict => {
|
|
349
|
+
const v = email.verdicts
|
|
350
|
+
if (v === null) return 'accept'
|
|
351
|
+
if (v.virus === 'FAIL') return 'drop'
|
|
352
|
+
return v.dmarc === 'FAIL' || (v.spf === 'FAIL' && v.dkim === 'FAIL') ? 'separate' : 'accept'
|
|
353
|
+
})
|
|
354
|
+
const log = getBaseLogger()
|
|
355
|
+
|
|
356
|
+
// Edges into `awaiting_approval`, out of it, and to `answered` are taken only by draft, discard, approve and a
|
|
357
|
+
// customer message (metadata.internal); `transition` cannot take them.
|
|
358
|
+
const internal = (ctx: { metadata?: Record<string, unknown> }): boolean => ctx.metadata?.['internal'] === true
|
|
359
|
+
const machine = defineStateMachine({
|
|
360
|
+
states: TICKET_STATES,
|
|
361
|
+
initial: 'open',
|
|
362
|
+
transitions: [
|
|
363
|
+
{ from: 'open', to: 'triaged' },
|
|
364
|
+
{ from: 'triaged', to: 'awaiting_approval', guard: internal },
|
|
365
|
+
{ from: 'awaiting_approval', to: ['triaged', 'answered'], guard: internal },
|
|
366
|
+
{ from: 'open', to: 'closed' },
|
|
367
|
+
{ from: 'triaged', to: 'closed' },
|
|
368
|
+
{ from: 'answered', to: 'closed' },
|
|
369
|
+
{ from: 'triaged', to: 'open', guard: internal },
|
|
370
|
+
{ from: 'awaiting_approval', to: 'open', guard: internal },
|
|
371
|
+
{ from: 'answered', to: 'open', guard: internal },
|
|
372
|
+
{ from: 'closed', to: 'open', guard: internal },
|
|
373
|
+
],
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
function tenantOf(explicit: string | null, flow: Flow): string {
|
|
377
|
+
if (explicit !== null) return requireUuid('tenantId', explicit)
|
|
378
|
+
if (flow.tenant === null) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
'[pipework] This support method needs a tenant in the Flow.\n\n' +
|
|
381
|
+
' Call it from an authenticated tenant route, or use the ...For(tenantId, ...) form\n' +
|
|
382
|
+
' (getFor, draftReplyFor, approveFor, ...) from a webhook or a job with no tenant.\n',
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
return flow.tenant
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function run<T>(explicit: string | null, fn: (tx: DB, tenantId: string, flow: Flow) => Promise<T>): Promise<T> {
|
|
389
|
+
const flow = requireContext()
|
|
390
|
+
const tenantId = tenantOf(explicit, flow)
|
|
391
|
+
if (explicit !== null) return inTenant(flow, tenantId, tx => fn(tx, tenantId, flow))
|
|
392
|
+
return pipe.transaction((tx: DB) => fn(tx, tenantId, flow))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// An audit record under the Flow's open trace step (none: nothing). In a transaction it is a savepoint, so a
|
|
396
|
+
// failed write cannot abort the change it describes.
|
|
397
|
+
async function audit(flow: Flow, tx: DB | null, tenantId: string, verb: string, entityId: string, details: Record<string, unknown>): Promise<void> {
|
|
398
|
+
const trace = flow.trace
|
|
399
|
+
const stepId = trace?.buffer.openStepId ?? null
|
|
400
|
+
if (trace === null || stepId === null) return
|
|
401
|
+
const write = async (db: DB): Promise<void> => {
|
|
402
|
+
await db.execute(sql`
|
|
403
|
+
INSERT INTO audit_record
|
|
404
|
+
(trace_day, trace_id, trace_step_id, tenant_id, actor_id, entity, verb, entity_id, after, occurred_at)
|
|
405
|
+
VALUES (${trace.openedDay}, ${trace.id}, ${stepId}, ${tenantId}, ${resolveActorId(flow)},
|
|
406
|
+
${ENTITY}, ${verb}, ${entityId}, ${JSON.stringify(details)}::jsonb, now())`)
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
if (tx === null) await write(pipeSystem())
|
|
410
|
+
else await tx.transaction(write)
|
|
411
|
+
} catch (err) {
|
|
412
|
+
log.error({ err, traceId: trace.id }, `Failed to write the support ${verb} audit record`)
|
|
413
|
+
void recordFault('audit-write', faultMessage(err), { entity: ENTITY, verb })
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function emit(tx: DB, kind: SupportEventKind, ticket: Ticket): Promise<void> {
|
|
418
|
+
const payload: SupportEventPayload = {
|
|
419
|
+
kind, ticketId: ticket.id, tenantId: ticket.tenantId, subject: ticket.subject, state: ticket.state,
|
|
420
|
+
category: ticket.category, at: new Date().toISOString(),
|
|
421
|
+
}
|
|
422
|
+
await tx.execute(sql`
|
|
423
|
+
INSERT INTO support_event_delivery (ticket_id, kind, sink, payload, created_at, next_attempt_at)
|
|
424
|
+
SELECT ${ticket.id}::uuid, ${kind}, s.sink, ${JSON.stringify(payload)}::jsonb, clock_timestamp(), clock_timestamp()
|
|
425
|
+
FROM jsonb_array_elements_text(${sinkNames}::jsonb) AS s(sink)`)
|
|
426
|
+
await config.queue.enqueue(tx, { type: jobType, payload: {}, continueFlow: true })
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function loadTicket(tx: DB, tenantId: string, ticketId: string, lock: boolean): Promise<Ticket> {
|
|
430
|
+
requireUuid('ticketId', ticketId)
|
|
431
|
+
const rows = lock
|
|
432
|
+
? await tx.execute(sql`SELECT * FROM support_ticket WHERE id = ${ticketId}::uuid AND tenant_id = ${tenantId}::uuid FOR UPDATE`)
|
|
433
|
+
: await tx.execute(sql`SELECT * FROM support_ticket WHERE id = ${ticketId}::uuid AND tenant_id = ${tenantId}::uuid`)
|
|
434
|
+
if (rows[0] === undefined) throw new NotFoundError('Support ticket', { ticketId })
|
|
435
|
+
return toTicket(rows[0])
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function loadDraftRow(tx: DB, tenantId: string, draftId: string): Promise<Record<string, unknown>> {
|
|
439
|
+
requireUuid('draftId', draftId)
|
|
440
|
+
const rows = await tx.execute(sql`SELECT * FROM support_message WHERE id = ${draftId}::uuid AND tenant_id = ${tenantId}::uuid AND kind = 'draft'`)
|
|
441
|
+
if (rows[0] === undefined) throw new NotFoundError('Support draft', { draftId })
|
|
442
|
+
return rows[0]
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function moveTo(flow: Flow, tx: DB, ticket: Ticket, to: TicketState, internalMove: boolean): Promise<Ticket> {
|
|
446
|
+
await machine.transition({ entityId: ticket.id, entityType: 'SupportTicket', from: ticket.state, to, metadata: { internal: internalMove } })
|
|
447
|
+
const rows = await tx.execute(sql`UPDATE support_ticket SET state = ${to}, updated_at = clock_timestamp() WHERE id = ${ticket.id}::uuid RETURNING *`)
|
|
448
|
+
await audit(flow, tx, ticket.tenantId, 'transition', ticket.id, { from: ticket.state, to })
|
|
449
|
+
return toTicket(rows[0]!)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function discardLive(flow: Flow, tx: DB, ticket: Ticket, reason: string): Promise<boolean> {
|
|
453
|
+
const rows = await tx.execute(sql`
|
|
454
|
+
UPDATE support_message SET draft_status = 'discarded'
|
|
455
|
+
WHERE ticket_id = ${ticket.id}::uuid AND kind = 'draft' AND draft_status = 'draft' RETURNING id`)
|
|
456
|
+
for (const r of rows) await audit(flow, tx, ticket.tenantId, 'discard', ticket.id, { draftId: r['id'], reason })
|
|
457
|
+
return rows.length > 0
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function lockConversation(tx: DB, tenantId: string, key: string): Promise<void> {
|
|
461
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`pipework.support:${tenantId}:${key}`}, 0))`)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
interface Incoming {
|
|
465
|
+
readonly channel: 'email' | 'chat' | 'api'
|
|
466
|
+
readonly contactEmail: string
|
|
467
|
+
readonly contactName: string | null
|
|
468
|
+
readonly userId: string | null
|
|
469
|
+
readonly subject: string
|
|
470
|
+
readonly text: string
|
|
471
|
+
readonly html: string | null
|
|
472
|
+
readonly fromAddress: string | null
|
|
473
|
+
readonly inboundEmailId: string | null
|
|
474
|
+
readonly conversationId: string | null
|
|
475
|
+
readonly attachments: unknown
|
|
476
|
+
readonly ticketId: string | null
|
|
477
|
+
/** Never add to an existing ticket. */
|
|
478
|
+
readonly separate?: boolean
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function ingest(flow: Flow, tx: DB, tenantId: string, m: Incoming): Promise<ReceiveResult> {
|
|
482
|
+
await lockConversation(tx, tenantId, m.conversationId ?? m.inboundEmailId ?? m.ticketId ?? m.contactEmail)
|
|
483
|
+
if (m.inboundEmailId !== null) {
|
|
484
|
+
const seen = await tx.execute(sql`SELECT ticket_id FROM support_message WHERE tenant_id = ${tenantId}::uuid AND inbound_email_id = ${m.inboundEmailId}::uuid`)
|
|
485
|
+
if (seen[0] !== undefined) return { status: 'duplicate', ticketId: seen[0]['ticket_id'] as string, created: false }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let ticket: Ticket | null = null
|
|
489
|
+
if (m.ticketId !== null) {
|
|
490
|
+
ticket = await loadTicket(tx, tenantId, m.ticketId, true)
|
|
491
|
+
if (joinThread === 'contact' && ticket.contactEmail !== m.contactEmail) {
|
|
492
|
+
throw new ForbiddenError('The sender is not the ticket\'s contact.', { ticketId: m.ticketId })
|
|
493
|
+
}
|
|
494
|
+
} else if (m.conversationId !== null && m.separate !== true) {
|
|
495
|
+
const contactOnly = joinThread === 'contact' ? sql`AND t.contact_email = ${m.contactEmail}` : sql``
|
|
496
|
+
const found = await tx.execute(sql`
|
|
497
|
+
SELECT t.id FROM support_ticket t
|
|
498
|
+
WHERE t.tenant_id = ${tenantId}::uuid ${contactOnly}
|
|
499
|
+
AND (t.conversation_id = ${m.conversationId}::uuid OR EXISTS (
|
|
500
|
+
SELECT 1 FROM support_message sm JOIN pipework_email_inbound i ON i.id = sm.inbound_email_id
|
|
501
|
+
WHERE sm.ticket_id = t.id AND i.conversation_id = ${m.conversationId}::uuid))
|
|
502
|
+
ORDER BY (t.conversation_id = ${m.conversationId}::uuid) DESC NULLS LAST, t.created_at DESC, t.id LIMIT 1`)
|
|
503
|
+
if (found[0] !== undefined) ticket = await loadTicket(tx, tenantId, found[0]['id'] as string, true)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const resolve = async (): Promise<string | null> =>
|
|
507
|
+
m.userId ?? (await config.resolveContact?.({ address: m.contactEmail, name: m.contactName, tenantId }, tx))?.userId ?? null
|
|
508
|
+
|
|
509
|
+
const joinedBy = ticket !== null && ticket.contactEmail !== m.contactEmail ? 'conversation' : null
|
|
510
|
+
let created = false
|
|
511
|
+
let userIdSet: string | null = null
|
|
512
|
+
if (ticket === null) {
|
|
513
|
+
const userId = await resolve()
|
|
514
|
+
let conversationId = m.conversationId
|
|
515
|
+
if (m.separate === true) conversationId = null
|
|
516
|
+
if (conversationId !== null) {
|
|
517
|
+
const held = await tx.execute(sql`SELECT 1 FROM support_ticket WHERE tenant_id = ${tenantId}::uuid AND conversation_id = ${conversationId}::uuid`)
|
|
518
|
+
if (held.length > 0) conversationId = null
|
|
519
|
+
}
|
|
520
|
+
const rows = await tx.execute(sql`
|
|
521
|
+
INSERT INTO support_ticket (tenant_id, state, subject, contact_email, contact_name, user_id, conversation_id)
|
|
522
|
+
VALUES (${tenantId}::uuid, 'open', ${m.subject}, ${m.contactEmail}, ${m.contactName}, ${userId}, ${conversationId}::uuid)
|
|
523
|
+
RETURNING *`)
|
|
524
|
+
ticket = toTicket(rows[0]!)
|
|
525
|
+
created = true
|
|
526
|
+
await audit(flow, tx, tenantId, 'create', ticket.id, { subject: ticket.subject, contact: ticket.contactEmail, channel: m.channel, userId: ticket.userId })
|
|
527
|
+
} else {
|
|
528
|
+
await discardLive(flow, tx, ticket, 'customer-replied')
|
|
529
|
+
if (ticket.state !== 'open') ticket = await moveTo(flow, tx, ticket, 'open', true)
|
|
530
|
+
const viaConversation = ticket.contactEmail !== m.contactEmail
|
|
531
|
+
const userId = ticket.userId === null && !viaConversation ? await resolve() : null
|
|
532
|
+
userIdSet = userId
|
|
533
|
+
const rows = await tx.execute(sql`UPDATE support_ticket SET last_customer_message_at = clock_timestamp(), updated_at = clock_timestamp(), user_id = COALESCE(user_id, ${userId}) WHERE id = ${ticket.id}::uuid RETURNING *`)
|
|
534
|
+
ticket = toTicket(rows[0]!)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
await tx.execute(sql`
|
|
538
|
+
INSERT INTO support_message (tenant_id, ticket_id, kind, channel, text, html, from_address, inbound_email_id, attachments)
|
|
539
|
+
VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${m.text}, ${m.html}, ${m.fromAddress},
|
|
540
|
+
${m.inboundEmailId}::uuid, ${m.attachments === null ? null : JSON.stringify(m.attachments)}::jsonb)`)
|
|
541
|
+
await audit(flow, tx, tenantId, 'receive', ticket.id, { channel: m.channel, emailId: m.inboundEmailId, created, separated: m.separate === true, ...(joinedBy === null ? {} : { joinedBy }), ...(userIdSet === null ? {} : { userIdSet }) })
|
|
542
|
+
await emit(tx, created ? 'ticket.created' : 'ticket.customer-replied', ticket)
|
|
543
|
+
return { status: 'received', ticketId: ticket.id, created }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function receiveEmail(email: ReceivedEmail): Promise<ReceiveResult> {
|
|
547
|
+
const flow = requireContext()
|
|
548
|
+
if (email.tenantId === null) {
|
|
549
|
+
log.warn({ emailId: email.id }, 'support: received email has no tenant; dropped')
|
|
550
|
+
void recordFault('support-inbound-untenanted', `received email ${email.id} has no tenant; dropped`, { emailId: email.id })
|
|
551
|
+
return { status: 'no-tenant' }
|
|
552
|
+
}
|
|
553
|
+
const tenantId = email.tenantId
|
|
554
|
+
const contactEmail = email.from.address.trim().toLowerCase()
|
|
555
|
+
const verdict = contactEmail === '' ? 'drop' : screen(email)
|
|
556
|
+
if (verdict === 'drop') {
|
|
557
|
+
await audit(flow, null, tenantId, 'drop', email.id, {
|
|
558
|
+
reason: contactEmail === '' ? 'no-sender' : 'screened', virus: email.verdicts?.virus ?? null,
|
|
559
|
+
})
|
|
560
|
+
return { status: 'dropped' }
|
|
561
|
+
}
|
|
562
|
+
const attachments = email.attachments.flatMap(a => a.file === null
|
|
563
|
+
? []
|
|
564
|
+
: [{ id: a.file.id, filename: a.filename, mediaType: a.mediaType, size: a.size }])
|
|
565
|
+
return inTenant(flow, tenantId, tx => ingest(flow, tx, tenantId, {
|
|
566
|
+
channel: 'email', contactEmail, contactName: email.from.name === '' ? null : email.from.name, userId: null,
|
|
567
|
+
subject: email.subject.trim() === '' ? '(no subject)' : email.subject, text: email.text ?? '', html: email.html,
|
|
568
|
+
fromAddress: contactEmail, inboundEmailId: email.id, conversationId: email.conversationId,
|
|
569
|
+
attachments: attachments.length === 0 ? null : attachments, ticketId: null, separate: verdict === 'separate',
|
|
570
|
+
}))
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function receive(tenantId: string, input: ReceiveInput): Promise<ReceiveResult> {
|
|
574
|
+
const flow = requireContext()
|
|
575
|
+
requireUuid('tenantId', tenantId)
|
|
576
|
+
nonEmpty('text', input.text)
|
|
577
|
+
const contactEmail = nonEmpty('contact.email', input.contact.email).trim().toLowerCase()
|
|
578
|
+
if ((input.channel as string) !== 'chat' && (input.channel as string) !== 'api') throw new ValidationError({ channel: ['must be chat or api'] }, 'support.receive: channel must be "chat" or "api".')
|
|
579
|
+
const firstLine = input.text.trim().split('\n')[0]!.slice(0, 200)
|
|
580
|
+
return inTenant(flow, tenantId, tx => ingest(flow, tx, tenantId, {
|
|
581
|
+
channel: input.channel, contactEmail, contactName: input.contact.name ?? null, userId: input.contact.userId ?? null,
|
|
582
|
+
subject: input.subject ?? firstLine, text: input.text, html: input.html ?? null, fromAddress: contactEmail,
|
|
583
|
+
inboundEmailId: null, conversationId: null, attachments: null, ticketId: input.ticketId ?? null,
|
|
584
|
+
}))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function getDetail(tx: DB, tenantId: string, ticketId: string): Promise<TicketDetail> {
|
|
588
|
+
const ticket = await loadTicket(tx, tenantId, ticketId, false)
|
|
589
|
+
const messages = await tx.execute(sql`SELECT * FROM support_message WHERE ticket_id = ${ticketId}::uuid ORDER BY created_at, id`)
|
|
590
|
+
const links = await tx.execute(sql`SELECT * FROM support_link WHERE ticket_id = ${ticketId}::uuid ORDER BY created_at, id`)
|
|
591
|
+
return { ticket, messages: messages.map(toMessage), links: links.map(toLink) }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function listTickets(tx: DB, tenantId: string | null, options: ListOptions = {}): Promise<ListPage> {
|
|
595
|
+
const limit = Math.min(options.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT)
|
|
596
|
+
if (!Number.isInteger(limit) || limit < 1) throw new ValidationError({ limit: ['must be a positive integer'] }, 'support.list: limit must be a positive integer.')
|
|
597
|
+
let cursorAt: string | null = null
|
|
598
|
+
let cursorId: string | null = null
|
|
599
|
+
if (options.cursor !== undefined) {
|
|
600
|
+
const [at, id] = Buffer.from(options.cursor, 'base64url').toString('utf-8').split('|')
|
|
601
|
+
if (at === undefined || id === undefined || !UUID_RE.test(id) || Number.isNaN(Date.parse(at))) {
|
|
602
|
+
throw new ValidationError({ cursor: ['is not a cursor from a previous page'] }, 'support.list: bad cursor.')
|
|
603
|
+
}
|
|
604
|
+
cursorAt = at
|
|
605
|
+
cursorId = id
|
|
606
|
+
}
|
|
607
|
+
const rows = await tx.execute(sql`
|
|
608
|
+
SELECT t.*, t.created_at::text AS created_at_raw FROM support_ticket t
|
|
609
|
+
WHERE (${tenantId}::uuid IS NULL OR t.tenant_id = ${tenantId}::uuid)
|
|
610
|
+
AND (${options.state ?? null}::text IS NULL OR t.state = ${options.state ?? null})
|
|
611
|
+
AND (${options.category ?? null}::text IS NULL OR t.category = ${options.category ?? null})
|
|
612
|
+
AND (${cursorAt}::timestamptz IS NULL OR (t.created_at, t.id) < (${cursorAt}::timestamptz, ${cursorId}::uuid))
|
|
613
|
+
ORDER BY t.created_at DESC, t.id DESC LIMIT ${limit + 1}`)
|
|
614
|
+
const page = rows.slice(0, limit)
|
|
615
|
+
const last = page[page.length - 1]
|
|
616
|
+
const next = rows.length > limit && last !== undefined
|
|
617
|
+
? Buffer.from(`${String(last['created_at_raw'])}|${String(last['id'])}`).toString('base64url')
|
|
618
|
+
: null
|
|
619
|
+
return { tickets: page.map(toTicket), next }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function setCategory(flow: Flow, tx: DB, tenantId: string, ticketId: string, category: string | null): Promise<Ticket> {
|
|
623
|
+
if (category !== null) nonEmpty('category', category)
|
|
624
|
+
const before = await loadTicket(tx, tenantId, ticketId, true)
|
|
625
|
+
const rows = await tx.execute(sql`UPDATE support_ticket SET category = ${category}, updated_at = clock_timestamp() WHERE id = ${ticketId}::uuid RETURNING *`)
|
|
626
|
+
await audit(flow, tx, tenantId, 'categorise', ticketId, { from: before.category, to: category })
|
|
627
|
+
return toTicket(rows[0]!)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function transition(flow: Flow, tx: DB, tenantId: string, ticketId: string, to: TicketState): Promise<Ticket> {
|
|
631
|
+
if (!(TICKET_STATES as readonly string[]).includes(to)) throw new ValidationError({ to: [`must be one of ${TICKET_STATES.join(', ')}`] }, `support.transition: unknown state "${String(to)}".`)
|
|
632
|
+
const ticket = await loadTicket(tx, tenantId, ticketId, true)
|
|
633
|
+
return moveTo(flow, tx, ticket, to, false)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function addNote(flow: Flow, tx: DB, tenantId: string, ticketId: string, text: string): Promise<TicketMessage> {
|
|
637
|
+
nonEmpty('text', text)
|
|
638
|
+
await loadTicket(tx, tenantId, ticketId, true)
|
|
639
|
+
const rows = await tx.execute(sql`
|
|
640
|
+
INSERT INTO support_message (tenant_id, ticket_id, kind, channel, text, created_by)
|
|
641
|
+
VALUES (${tenantId}::uuid, ${ticketId}::uuid, 'note', 'api', ${text}, ${resolveActorId(flow)}) RETURNING *`)
|
|
642
|
+
await tx.execute(sql`UPDATE support_ticket SET updated_at = clock_timestamp() WHERE id = ${ticketId}::uuid`)
|
|
643
|
+
await audit(flow, tx, tenantId, 'note', ticketId, { messageId: rows[0]!['id'] })
|
|
644
|
+
return toMessage(rows[0]!)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function link(flow: Flow, tx: DB, tenantId: string, ticketId: string, l: { kind: string; ref: string; label?: string }): Promise<TicketLink> {
|
|
648
|
+
nonEmpty('kind', l.kind)
|
|
649
|
+
nonEmpty('ref', l.ref)
|
|
650
|
+
await loadTicket(tx, tenantId, ticketId, true)
|
|
651
|
+
await tx.execute(sql`
|
|
652
|
+
INSERT INTO support_link (tenant_id, ticket_id, kind, ref, label)
|
|
653
|
+
VALUES (${tenantId}::uuid, ${ticketId}::uuid, ${l.kind}, ${l.ref}, ${l.label ?? null})
|
|
654
|
+
ON CONFLICT (ticket_id, kind, ref) DO UPDATE SET label = COALESCE(EXCLUDED.label, support_link.label)`)
|
|
655
|
+
const rows = await tx.execute(sql`SELECT * FROM support_link WHERE ticket_id = ${ticketId}::uuid AND kind = ${l.kind} AND ref = ${l.ref}`)
|
|
656
|
+
await audit(flow, tx, tenantId, 'link', ticketId, { kind: l.kind, ref: l.ref })
|
|
657
|
+
return toLink(rows[0]!)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function draftReply(flow: Flow, tx: DB, tenantId: string, ticketId: string, draft: DraftInput): Promise<{ draftId: string; version: number }> {
|
|
661
|
+
nonEmpty('text', draft.text)
|
|
662
|
+
if (draft.subject !== undefined) nonEmpty('subject', draft.subject)
|
|
663
|
+
let ticket = await loadTicket(tx, tenantId, ticketId, true)
|
|
664
|
+
if (ticket.state === 'awaiting_approval') {
|
|
665
|
+
await discardLive(flow, tx, ticket, 'replaced')
|
|
666
|
+
} else {
|
|
667
|
+
// From open the draft passes through triaged: both moves are taken and audited.
|
|
668
|
+
if (ticket.state === 'open') ticket = await moveTo(flow, tx, ticket, 'triaged', true)
|
|
669
|
+
ticket = await moveTo(flow, tx, ticket, 'awaiting_approval', true)
|
|
670
|
+
}
|
|
671
|
+
const rows = await tx.execute(sql`
|
|
672
|
+
INSERT INTO support_message (tenant_id, ticket_id, kind, channel, subject, text, html, draft_status, created_by)
|
|
673
|
+
VALUES (${tenantId}::uuid, ${ticketId}::uuid, 'draft', 'email', ${draft.subject ?? null}, ${draft.text}, ${draft.html ?? null}, 'draft', ${resolveActorId(flow)})
|
|
674
|
+
RETURNING id, version`)
|
|
675
|
+
const draftId = rows[0]!['id'] as string
|
|
676
|
+
await audit(flow, tx, tenantId, 'draft', ticketId, { draftId, version: 1 })
|
|
677
|
+
await emit(tx, 'draft.awaiting-approval', ticket)
|
|
678
|
+
return { draftId, version: Number(rows[0]!['version']) }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function editDraft(flow: Flow, tx: DB, tenantId: string, draftId: string, patch: Partial<DraftInput>): Promise<{ draftId: string; version: number }> {
|
|
682
|
+
if (patch.text !== undefined) nonEmpty('text', patch.text)
|
|
683
|
+
if (patch.subject !== undefined) nonEmpty('subject', patch.subject)
|
|
684
|
+
const row = await loadDraftRow(tx, tenantId, draftId)
|
|
685
|
+
await loadTicket(tx, tenantId, row['ticket_id'] as string, true)
|
|
686
|
+
const rows = await tx.execute(sql`
|
|
687
|
+
UPDATE support_message SET
|
|
688
|
+
text = COALESCE(${patch.text ?? null}, text), html = COALESCE(${patch.html ?? null}, html),
|
|
689
|
+
subject = COALESCE(${patch.subject ?? null}, subject), version = version + 1, created_by = ${resolveActorId(flow)}
|
|
690
|
+
WHERE id = ${draftId}::uuid AND draft_status = 'draft' RETURNING version`)
|
|
691
|
+
if (rows[0] === undefined) throw staleDraft(String(row['draft_status']))
|
|
692
|
+
const version = Number(rows[0]['version'])
|
|
693
|
+
await audit(flow, tx, tenantId, 'draft', row['ticket_id'] as string, { draftId, version, edited: true })
|
|
694
|
+
return { draftId, version }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function staleDraft(status: string, version?: { expected: number; actual: number }): DraftStateError {
|
|
698
|
+
if (status === 'approved') return new DraftStateError('approved', 'This draft is already approved.')
|
|
699
|
+
if (status === 'discarded') return new DraftStateError('discarded', 'This draft was discarded.')
|
|
700
|
+
return new DraftStateError('changed', `This draft changed since it was read (version ${String(version?.actual)}, expected ${String(version?.expected)}).`)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function discardDraft(flow: Flow, tx: DB, tenantId: string, draftId: string): Promise<void> {
|
|
704
|
+
const row = await loadDraftRow(tx, tenantId, draftId)
|
|
705
|
+
const ticket = await loadTicket(tx, tenantId, row['ticket_id'] as string, true)
|
|
706
|
+
const rows = await tx.execute(sql`UPDATE support_message SET draft_status = 'discarded' WHERE id = ${draftId}::uuid AND draft_status = 'draft' RETURNING id`)
|
|
707
|
+
if (rows[0] === undefined) throw staleDraft(String(row['draft_status']))
|
|
708
|
+
await audit(flow, tx, tenantId, 'discard', ticket.id, { draftId, reason: 'discarded' })
|
|
709
|
+
await moveTo(flow, tx, ticket, 'triaged', true)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function approve(explicit: string | null, draftId: string, options: { expectedVersion: number }): Promise<{ messageId: string; emailMessageId: string }> {
|
|
713
|
+
const flow = requireContext()
|
|
714
|
+
const tenantId = tenantOf(explicit, flow)
|
|
715
|
+
const actorId = resolveActorId(flow)
|
|
716
|
+
if (actorId === null) throw new ForbiddenError('Approving a reply needs an authenticated actor.')
|
|
717
|
+
if (!Number.isInteger(options.expectedVersion)) throw new ValidationError({ expectedVersion: ['must be an integer'] }, 'support.approve needs the expectedVersion the approver read.')
|
|
718
|
+
if (!(await config.canApprove({ actorId, tenantId }))) {
|
|
719
|
+
throw new ForbiddenError('This actor may not approve replies.', { actorId, tenantId })
|
|
720
|
+
}
|
|
721
|
+
return run(explicit, async (tx) => {
|
|
722
|
+
// The ticket row is locked first, so a customer message, a discard and a second approve wait for this one.
|
|
723
|
+
const row = await loadDraftRow(tx, tenantId, draftId)
|
|
724
|
+
const ticket = await loadTicket(tx, tenantId, row['ticket_id'] as string, true)
|
|
725
|
+
const draft = toMessage(await loadDraftRow(tx, tenantId, draftId))
|
|
726
|
+
if (distinctApprover && draft.createdBy === actorId) {
|
|
727
|
+
throw new ForbiddenError('A draft cannot be approved by its author.', { actorId })
|
|
728
|
+
}
|
|
729
|
+
const claimed = await tx.execute(sql`
|
|
730
|
+
UPDATE support_message SET draft_status = 'approved', approved_by = ${actorId}, approved_at = clock_timestamp()
|
|
731
|
+
WHERE id = ${draftId}::uuid AND draft_status = 'draft' AND version = ${options.expectedVersion}
|
|
732
|
+
RETURNING *`)
|
|
733
|
+
if (claimed[0] === undefined) throw staleDraft(draft.draftStatus ?? 'changed', { expected: options.expectedVersion, actual: draft.version })
|
|
734
|
+
// What is sent is what the update matched, not the earlier read.
|
|
735
|
+
const approved = toMessage(claimed[0])
|
|
736
|
+
if (ticket.state !== 'awaiting_approval') {
|
|
737
|
+
throw new DraftStateError('not-awaiting-approval', `The ticket is ${ticket.state}, not awaiting approval.`)
|
|
738
|
+
}
|
|
739
|
+
const parent = await tx.execute(sql`
|
|
740
|
+
SELECT inbound_email_id FROM support_message
|
|
741
|
+
WHERE ticket_id = ${ticket.id}::uuid AND kind = 'inbound' AND inbound_email_id IS NOT NULL
|
|
742
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`)
|
|
743
|
+
const inReplyTo = (parent[0]?.['inbound_email_id'] ?? null) as string | null
|
|
744
|
+
const subject = approved.subject ?? (/^re:/i.test(ticket.subject) ? ticket.subject : `Re: ${ticket.subject}`)
|
|
745
|
+
const sent = await config.mail.send(tx, {
|
|
746
|
+
to: ticket.contactEmail, template: replyTemplate,
|
|
747
|
+
data: { subject, text: approved.text, ...(approved.html !== null ? { html: approved.html } : {}) },
|
|
748
|
+
key: `support-reply:${draftId}`,
|
|
749
|
+
...(ticket.conversationId !== null ? { conversationId: ticket.conversationId } : {}),
|
|
750
|
+
...(inReplyTo !== null ? { inReplyTo } : {}),
|
|
751
|
+
})
|
|
752
|
+
await tx.execute(sql`UPDATE support_message SET email_message_id = ${sent.messageId}::uuid WHERE id = ${draftId}::uuid`)
|
|
753
|
+
const outbound = await tx.execute(sql`
|
|
754
|
+
INSERT INTO support_message (tenant_id, ticket_id, kind, channel, subject, text, html, approved_by, approved_at, email_message_id, created_by)
|
|
755
|
+
VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'outbound', 'email', ${subject}, ${approved.text}, ${approved.html}, ${actorId},
|
|
756
|
+
clock_timestamp(), ${sent.messageId}::uuid, ${approved.createdBy})
|
|
757
|
+
RETURNING id`)
|
|
758
|
+
await moveTo(flow, tx, ticket, 'answered', true)
|
|
759
|
+
await audit(flow, tx, tenantId, 'approve', ticket.id, { draftId, version: draft.version, approvedBy: actorId, emailMessageId: sent.messageId, author: draft.createdBy })
|
|
760
|
+
return { messageId: outbound[0]!['id'] as string, emailMessageId: sent.messageId }
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const outbox = createOutbox<SupportDelivery>({
|
|
765
|
+
table: 'support_event_delivery', groupColumn: 'ticket_id', database: undefined, sinks,
|
|
766
|
+
maxAttempts: config.maxAttempts ?? 10, backoffMs: config.backoffMs ?? 5000, label: 'support event delivery given up',
|
|
767
|
+
toDelivery: (row, attempt) => ({
|
|
768
|
+
id: row['id'] as string, kind: row['kind'] as SupportEventKind, key: row['ticket_id'] as string, sink: row['sink'] as string,
|
|
769
|
+
payload: row['payload'] as SupportEventPayload, attempt,
|
|
770
|
+
}),
|
|
771
|
+
onDelivered: (row, attempt) => auditDelivery('deliver', row, { attempt }),
|
|
772
|
+
onGivenUp: (row, attempt, error) => auditDelivery('deliver-failed', row, { attempt, error }),
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
async function auditDelivery(verb: string, row: Record<string, unknown>, extra: Record<string, unknown>): Promise<void> {
|
|
776
|
+
const flow = requireContext()
|
|
777
|
+
const payload = row['payload'] as SupportEventPayload
|
|
778
|
+
await audit(flow, null, payload.tenantId, verb, row['ticket_id'] as string, { sink: row['sink'], kind: row['kind'], ...extra })
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const drainStep = __flowStep({ ref: 'pipework/support:tick' }, () => outbox.drain())
|
|
782
|
+
// A beat for `alerts.source.loopStalled`, written only where the blueprint has `alerts`.
|
|
783
|
+
const beat = async (outcome: { ok: boolean; error?: string }): Promise<void> => {
|
|
784
|
+
const ctx = getContext()
|
|
785
|
+
if (ctx === undefined || !beatsEnabled(ctx.instance.config)) return
|
|
786
|
+
await recordBeat(() => pipeSystem(), 'support.events', { ...outcome, everyMs: 60_000 })
|
|
787
|
+
}
|
|
788
|
+
const tickStep = async () => {
|
|
789
|
+
try {
|
|
790
|
+
const result = await drainStep()
|
|
791
|
+
await beat({ ok: true })
|
|
792
|
+
return result
|
|
793
|
+
} catch (err) {
|
|
794
|
+
await beat({ ok: false, error: describeError(err).message })
|
|
795
|
+
throw err
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const job = fitting.use().job(jobType).fit(async () => tickStep())
|
|
799
|
+
|
|
800
|
+
// Each call exists twice: for the Flow's tenant, and as `<name>For(tenantId, ...)`.
|
|
801
|
+
const ops: Record<Exclude<keyof TicketApi, 'approve'>, (flow: Flow, tx: DB, tenantId: string, ...args: never[]) => Promise<unknown>> = {
|
|
802
|
+
get: (_f, tx, t, id: string) => getDetail(tx, t, id),
|
|
803
|
+
list: (_f, tx, t, o?: ListOptions) => listTickets(tx, t, o),
|
|
804
|
+
setCategory: (f, tx, t, id: string, c: string | null) => setCategory(f, tx, t, id, c),
|
|
805
|
+
transition: (f, tx, t, id: string, to: TicketState) => transition(f, tx, t, id, to),
|
|
806
|
+
addNote: (f, tx, t, id: string, text: string) => addNote(f, tx, t, id, text),
|
|
807
|
+
link: (f, tx, t, id: string, l: { kind: string; ref: string; label?: string }) => link(f, tx, t, id, l),
|
|
808
|
+
draftReply: (f, tx, t, id: string, d: DraftInput) => draftReply(f, tx, t, id, d),
|
|
809
|
+
editDraft: (f, tx, t, id: string, d: Partial<DraftInput>) => editDraft(f, tx, t, id, d),
|
|
810
|
+
discardDraft: (f, tx, t, id: string) => discardDraft(f, tx, t, id),
|
|
811
|
+
}
|
|
812
|
+
const api: Record<string, unknown> = {
|
|
813
|
+
approve: (draftId: string, options: { expectedVersion: number }) => approve(null, draftId, options),
|
|
814
|
+
approveFor: (tenantId: string, draftId: string, options: { expectedVersion: number }) => approve(tenantId, draftId, options),
|
|
815
|
+
}
|
|
816
|
+
for (const [name, fn] of Object.entries(ops)) {
|
|
817
|
+
const call = fn as (flow: Flow, tx: DB, tenantId: string, ...args: unknown[]) => Promise<unknown>
|
|
818
|
+
api[name] = (...args: unknown[]) => run(null, (tx, t, f) => call(f, tx, t, ...args))
|
|
819
|
+
api[`${name}For`] = (tenantId: string, ...args: unknown[]) => run(tenantId, (tx, t, f) => call(f, tx, t, ...args))
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
...(api as unknown as WithFor<TicketApi>),
|
|
824
|
+
receiveEmail,
|
|
825
|
+
receive,
|
|
826
|
+
async listAll(options = {}) {
|
|
827
|
+
requireContext()
|
|
828
|
+
const { tenantId, ...rest } = options
|
|
829
|
+
if (tenantId !== undefined) requireUuid('tenantId', tenantId)
|
|
830
|
+
return listTickets(pipeSystem(), tenantId ?? null, rest)
|
|
831
|
+
},
|
|
832
|
+
job: job as unknown as Handler<Record<string, unknown>, unknown>,
|
|
833
|
+
tick: () => tickStep(),
|
|
834
|
+
async requeueFailed(options) {
|
|
835
|
+
requireContext()
|
|
836
|
+
nonEmpty('by', options.by)
|
|
837
|
+
const requeued = await outbox.requeue({ ...(options.sink !== undefined ? { sink: options.sink } : {}), ...(options.failedSince !== undefined ? { failedSince: options.failedSince } : {}) })
|
|
838
|
+
for (const row of requeued) {
|
|
839
|
+
await auditDelivery('requeue', row, { by: options.by.trim(), attempts: Number(row['prev_attempts']), error: row['last_error'] ?? null })
|
|
840
|
+
}
|
|
841
|
+
return { requeued: requeued.length }
|
|
842
|
+
},
|
|
843
|
+
async deliveries(options = {}) {
|
|
844
|
+
const rows = options.ticketId === undefined
|
|
845
|
+
? await pipeSystem().execute(sql`SELECT * FROM support_event_delivery ORDER BY created_at, id`)
|
|
846
|
+
: await pipeSystem().execute(sql`SELECT * FROM support_event_delivery WHERE ticket_id = ${options.ticketId}::uuid ORDER BY created_at, id`)
|
|
847
|
+
return rows.map(r => ({
|
|
848
|
+
id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as SupportEventKind, sink: r['sink'] as string,
|
|
849
|
+
attempts: Number(r['attempts']), deliveredAt: r['delivered_at'] === null ? null : toDate(r['delivered_at']),
|
|
850
|
+
failedAt: r['failed_at'] === null ? null : toDate(r['failed_at']), lastError: (r['last_error'] ?? null) as string | null,
|
|
851
|
+
}))
|
|
852
|
+
},
|
|
853
|
+
}
|
|
854
|
+
}
|