Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export class ConnectionIngestionTrustResponseDto {
})
connectionCreatedAt!: string;

@ApiProperty({
nullable: true,
description:
'Earliest ingested order date (ISO 8601) for this connection — MIN(placedAt) falling back to ' +
'createdAt for pre-#1985 rows. Null when the connection has zero ingested orders. The real ' +
'per-channel coverage-window fact; do not confuse with connectionCreatedAt.',
})
earliestOrderDate!: string | null;

@ApiProperty({
nullable: true,
description:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('AnalyticsTrustController', () => {
lastPollAt: new Date('2026-06-01T11:55:00.000Z'),
lastOrderIngestedAt: new Date('2026-06-01T10:00:00.000Z'),
connectionCreatedAt: new Date('2026-01-01T00:00:00.000Z'),
earliestOrderDate: new Date('2026-02-10T00:00:00.000Z'),
expectedIntervalMs: 300_000,
staleAfterMs: 900_000,
},
Expand All @@ -50,6 +51,7 @@ describe('AnalyticsTrustController', () => {
lastPollAt: '2026-06-01T11:55:00.000Z',
lastOrderIngestedAt: '2026-06-01T10:00:00.000Z',
connectionCreatedAt: '2026-01-01T00:00:00.000Z',
earliestOrderDate: '2026-02-10T00:00:00.000Z',
expectedIntervalMs: 300_000,
staleAfterMs: 900_000,
});
Expand All @@ -69,6 +71,7 @@ describe('AnalyticsTrustController', () => {
lastPollAt: null,
lastOrderIngestedAt: null,
connectionCreatedAt: new Date('2026-05-30T00:00:00.000Z'),
earliestOrderDate: null,
expectedIntervalMs: null,
staleAfterMs: null,
},
Expand Down Expand Up @@ -97,6 +100,7 @@ describe('AnalyticsTrustController', () => {
lastPollAt: null,
lastOrderIngestedAt: null,
connectionCreatedAt: new Date('2026-05-30T00:00:00.000Z'),
earliestOrderDate: null,
expectedIntervalMs: null,
staleAfterMs: null,
},
Expand Down Expand Up @@ -124,6 +128,7 @@ describe('AnalyticsTrustController', () => {
lastPollAt: new Date('2026-05-25T00:00:00.000Z'),
lastOrderIngestedAt: new Date('2026-05-24T00:00:00.000Z'),
connectionCreatedAt: new Date('2026-01-01T00:00:00.000Z'),
earliestOrderDate: null,
expectedIntervalMs: 300_000,
staleAfterMs: 900_000,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export class AnalyticsTrustController {
? entry.lastOrderIngestedAt.toISOString()
: null;
connectionDto.connectionCreatedAt = entry.connectionCreatedAt.toISOString();
connectionDto.earliestOrderDate = entry.earliestOrderDate
? entry.earliestOrderDate.toISOString()
: null;
connectionDto.expectedIntervalMs = entry.expectedIntervalMs;
connectionDto.staleAfterMs = entry.staleAfterMs;
return connectionDto;
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/orders/http/orders.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('OrdersController', () => {
const mockRepository: jest.Mocked<OrderRecordRepositoryPort> = {
findById: jest.fn(),
findByIds: jest.fn(),
findEarliestPlacedAtByConnection: jest.fn(),
upsert: jest.fn(),
upsertWithLineItems: jest.fn(),
updateSyncStatus: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/orders/http/refunds.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('RefundsController', () => {
findByIds: jest.fn(),
updateFulfillmentState: jest.fn(),
markCancelled: jest.fn(),
getEarliestOrderDateByConnection: jest.fn(),
markItemResolutionFailure: jest.fn(),
getFailedSyncValueSummary: jest.fn(),
};
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/shipping/http/shipment.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ describe('ShipmentController', () => {
markItemResolutionFailure: jest.fn(),
getFailedSyncValueSummary: jest.fn(),
markCancelled: jest.fn(),
getEarliestOrderDateByConnection: jest.fn(),
};
controller = new ShipmentController(
query,
Expand Down
99 changes: 99 additions & 0 deletions apps/api/test/integration/earliest-order-date.int-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Earliest Order Date Integration Test (#2083)
*
* Exercises the real `OrderRecordRepository.findEarliestPlacedAtByConnection`
* — the batched `MIN(COALESCE("placedAt", "createdAt"))` `GROUP BY` query —
* against Testcontainers Postgres. A mocked query builder can only assert
* that the right SQL fragments were requested; this asserts the aggregate
* actually computes the right answer over a mixed null/non-null `placedAt`
* population and that the `pg` driver hands back a real `Date` for the raw
* `earliest_at` alias (the repository types it `Date` on faith).
*
* @module apps/api/test/integration
*/
import type { IntegrationTestHarness } from './setup';
import { getTestHarness, resetTestHarness, teardownTestHarness } from './setup';
import { createTestOrderRecord } from './fixtures/order.fixtures';
import type { OrderRecordRepositoryPort } from '@openlinker/core/orders';
import { ORDER_RECORD_REPOSITORY_TOKEN } from '@openlinker/core/orders';

const CONNECTION_A = '11111111-1111-4111-8111-111111111111';
const CONNECTION_B = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const CONNECTION_C = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';

describe('Earliest order date by connection (integration)', () => {
let harness: IntegrationTestHarness;
let repository: OrderRecordRepositoryPort;

beforeAll(async () => {
harness = await getTestHarness();
repository = harness.getApp().get<OrderRecordRepositoryPort>(ORDER_RECORD_REPOSITORY_TOKEN);
});

afterEach(async () => {
await resetTestHarness();
});

afterAll(async () => {
await teardownTestHarness();
});

it('returns MIN(COALESCE(placedAt, createdAt)) per connection as a real Date', async () => {
const ds = harness.getDataSource();
await createTestOrderRecord(ds, {
sourceConnectionId: CONNECTION_A,
placedAt: new Date('2026-03-01T00:00:00Z'),
createdAt: new Date('2026-03-02T00:00:00Z'),
});
// Earlier placedAt on the same connection — must win the MIN.
await createTestOrderRecord(ds, {
sourceConnectionId: CONNECTION_A,
placedAt: new Date('2026-01-15T00:00:00Z'),
createdAt: new Date('2026-01-16T00:00:00Z'),
});
// No placedAt asserted by the source — falls back to createdAt.
await createTestOrderRecord(ds, {
sourceConnectionId: CONNECTION_B,
placedAt: null,
createdAt: new Date('2026-02-10T00:00:00Z'),
});

const result = await repository.findEarliestPlacedAtByConnection([
CONNECTION_A,
CONNECTION_B,
CONNECTION_C,
]);

const earliestA = result.get(CONNECTION_A);
expect(earliestA).toBeInstanceOf(Date);
expect(earliestA?.toISOString()).toBe(new Date('2026-01-15T00:00:00Z').toISOString());

const earliestB = result.get(CONNECTION_B);
expect(earliestB).toBeInstanceOf(Date);
expect(earliestB?.toISOString()).toBe(new Date('2026-02-10T00:00:00Z').toISOString());

// No orders at all for this connection — absent, not a zeroed entry.
expect(result.has(CONNECTION_C)).toBe(false);
});

it('ignores recordStatus — the coverage window is unfiltered by design', async () => {
const ds = harness.getDataSource();
await createTestOrderRecord(ds, {
sourceConnectionId: CONNECTION_A,
recordStatus: 'awaiting_mapping',
placedAt: new Date('2026-01-01T00:00:00Z'),
});

const result = await repository.findEarliestPlacedAtByConnection([CONNECTION_A]);

expect(result.get(CONNECTION_A)?.toISOString()).toBe(
new Date('2026-01-01T00:00:00Z').toISOString()
);
});

it('returns an empty Map without querying when given no connection ids', async () => {
const result = await repository.findEarliestPlacedAtByConnection([]);

expect(result.size).toBe(0);
});
});
4 changes: 3 additions & 1 deletion docs/architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,14 +307,15 @@ The system is organized into the following core bounded contexts:

### 15. Analytics Trust

- **Responsibility**: A read-only data-trust signal for the analytics UI — per `OrderSource`-capable connection, whether its ingestion pipe is live and whether its order data is recent, so an operator reading a revenue/order chart can tell "sales dropped" apart from "the poll died" (#1982). No entities, no persistence, no invariants of its own — a composition of two existing seams (`IIntegrationsService`, `ISyncJobsService`) plus two pure domain functions.
- **Responsibility**: A read-only data-trust signal for the analytics UI — per `OrderSource`-capable connection, whether its ingestion pipe is live and whether its order data is recent, so an operator reading a revenue/order chart can tell "sales dropped" apart from "the poll died" (#1982). No entities, no persistence, no invariants of its own — a composition of three existing seams (`IIntegrationsService`, `ISyncJobsService`, `IOrderRecordService`) plus two pure domain functions.
- **Location**: `libs/core/src/analytics-trust/`.
- **Distinct from `libs/core/src/analytics/`** (PostHog settings) — adjacent names, unrelated subject matter.
- **`AnalyticsTrustService`** enumerates `OrderSource`-capable connections via `listCapabilityAdapters({ capability: 'OrderSource', lazy: true, includeAllStatuses: true })` — `includeAllStatuses` (opt-in, default off) is required here: the single most common real ingestion death is a token flipping a connection to `needs_reauth`, and the default `active`-only filter would silently drop exactly the connections this read exists to warn about. A connection whose own `status` isn't `'active'` is always classified `'disconnected'`, overriding whatever its poll history would otherwise say.
- **Poll liveness vs. data recency are reported as two independent facts**, not one. `lastPollAt` (last succeeded `marketplace.orders.poll` job) is a pipe-liveness signal, thresholded against a staleness window; `lastOrderIngestedAt` (last succeeded `marketplace.order.sync` job, same connection) is the actual order-data-recency signal and is deliberately never thresholded — a low-volume connection can go days without a new order and still be healthy. Both job types are always looked up regardless of whether a poll scheduler task is registered for the platform, since a poll task can be legitimately disabled on a webhook-first platform (PrestaShop, WooCommerce) while the connection keeps ingesting fine.
- **The staleness threshold is derived only from a currently-*enabled* scheduler task** (`ISyncJobsService.findEnabledPollTask`, mirroring `SchedulerService`'s own `enabledEnvVar`/`enabledDefault` runtime check) — never from mere task *registration*, since WooCommerce/Erli register their poll task unconditionally and gate only its execution. When no enabled task matches, the threshold falls back to a 30-minute floor rather than going unset, so an unknown-cadence connection can still eventually read `'stalled'`.
- **`ConnectionIngestionStatus`** (`never-ingested | fresh | stalled | disconnected | unknown`) — `'unknown'` is a distinct degraded value for a per-connection build failure (never `'never-ingested'`, which would assert a false claim about the operator's data for what is really an infrastructure hiccup); `computeWorstStatus` rolls the per-connection statuses up to one `worstStatus` for the FE banner, ranked `fresh < never-ingested < stalled < disconnected < unknown`.
- **Cross-context seam discipline**: the new context does not inject `SyncJobRepositoryPort` or the concrete `SchedulerTaskRegistryService` directly — it consumes both through the existing published `ISyncJobsService` interface (extended with `findLastSucceededJob` and `findEnabledPollTask`), keeping the cross-context contract to `I*Service` per the rule in § Cross-context dependencies in core.
- **Real per-connection earliest-order-date coverage window (#2083)**: `connectionCreatedAt` (when the operator configured the integration) was never a valid proxy for "how far back this connection's data goes" — a connection can legitimately ingest orders placed before it was created (e.g. Allegro's event journal seeded from the beginning). `ConnectionIngestionTrust.earliestOrderDate` is the real fact, `MIN(COALESCE(placedAt, createdAt))` over the connection's `order_records`, read through `IOrderRecordService.getEarliestOrderDateByConnection` — never `OrderRecordRepositoryPort` directly, mirroring the `getFailedSyncValueSummary` (#1983) cross-context precedent. `AnalyticsTrustService.getIngestionTrustSnapshot` calls it exactly **once**, batched across every enumerated connection id, before fanning out into the (pre-existing, per-connection) job-lookup loop — never inside that loop, which would reintroduce an N+1 query for this one field. A connection absent from the returned Map (zero ingested orders) reports `earliestOrderDate: null`, distinct from a non-null value that merely predates the `placedAt` backfill and resolved through the `createdAt` fallback instead.
- **Interface**: `GET /analytics/trust` (`apps/api/src/analytics-trust/`), guarded by the global `JwtAuthGuard`.

---
Expand Down Expand Up @@ -1336,6 +1337,7 @@ graph LR
mailer --> integrations
analytics-trust --> integrations
analytics-trust --> sync
analytics-trust --> orders
```

`identifier-mapping`, `integrations`, and `events` form the most-depended-upon "infrastructure spine" (each used by 5+ siblings). `users`, `webhooks`, and `mappings` have minimal outbound coupling.
Expand Down
Loading
Loading