Skip to content

Commit b5b2273

Browse files
coladarciclaude
andauthored
feat: add ACME backoffice accessor (#20)
Clone the CNG REST accessor for the ACME backoffice. Same shared auth, transport, retry loop, and tooling — differs only in routing, endpoint, and response shape: - extendable slug acme_backoffice_api@v1 (note @v1 separator) - endpoint v2/b2b/event/templates/names?pageSize=-1&page=1 - { list: [...] } envelope; converter filters to published templates and maps colorCategory.backgroundColor -> color - tickets always empty; exposes id/name/color only Public surface: AcmeAccessService (+config), AcmeProductService, AcmeActivity/AcmeActivityTicket, AcmeApiError. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 950f2de commit b5b2273

14 files changed

Lines changed: 738 additions & 10 deletions

docs/internal/ARCHITECTURE.md

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ call typed methods like `peek.getProductService().getAllProducts()` or directly
1111
via the top-level short-forms like `peek.getAllProducts()` and
1212
`peek.getAllActivities()`.
1313

14-
The package also ships a **sibling accessor for the CNG backoffice**,
15-
`CngAccessService` (REST, not GraphQL). It reuses this package's auth
16-
(`TokenManager`), retry/backoff loop, `Logger`, base error types, tooling, and
17-
the Odyssey UI — differing only in transport (REST vs GraphQL) and gateway
18-
routing (`cng_backoffice_api-v1` vs `peek_backoffice_api-v1`). See
19-
"CNG accessor" below.
14+
The package also ships **sibling accessors for other backoffices**
15+
`CngAccessService` and `AcmeAccessService` (both REST, not GraphQL). They reuse
16+
this package's auth (`TokenManager`), retry/backoff loop, `Logger`, base error
17+
types, tooling, and the Odyssey UI — differing only in transport (REST vs
18+
GraphQL) and gateway routing (`cng_backoffice_api-v1` /
19+
`acme_backoffice_api@v1` vs `peek_backoffice_api-v1`). See "CNG accessor" and
20+
"ACME accessor" below.
2021

2122
## Layers
2223

@@ -234,7 +235,9 @@ Recurring patterns inside services:
234235
The barrel re-exports only the public contract: `PeekAccessService` + its config,
235236
each resource service class (and the options/result types callers need), all
236237
data-model **types** (including `PeekAuthTokenClaims` and `PeekAuthTokenUser`),
237-
the `Logger` interface + `noopLogger`, and the three typed error classes. Query strings and raw response interfaces are deliberately kept
238+
the `Logger` interface + `noopLogger`, and the typed error classes
239+
(`AdminAccountRequiredError`, `RateLimitError`, `PeekGraphQLError`,
240+
`CngApiError`, `AcmeApiError`). Query strings and raw response interfaces are deliberately kept
238241
internal — including the booking-webhook registration query
239242
(`BOOKING_WEBHOOK_GQL_QUERY` stays internal, documented via `docs/webhooks.md`).
240243
The webhook-related public exports are the two parsers `parseBookingWebhook` and
@@ -287,6 +290,40 @@ plumbing rather than forking the package.
287290
> `cng/products/product-queries.ts`, `product-converter.ts`, and
288291
> `models/cng/product.ts`.
289292
293+
### 5c. ACME accessor (REST)
294+
`src/acme-access-service.ts`, `src/internal/acme/`, `src/models/acme/product.ts`
295+
296+
A third, brand-parallel accessor for the **ACME** backoffice, built by cloning
297+
the CNG accessor. Same auth/transport/tooling reuse as CNG (§5b) — only the
298+
routing, endpoint, and response shape differ:
299+
300+
- **`AcmeAccessService`** — identical to `CngAccessService`: validates the same
301+
four config fields (`installId`, `jwtSecret`, `issuer`, `appId`; no
302+
`gatewayKey`), builds the shared `TokenManager` + a `RestClient`, defaults to
303+
the app-registry base URL, and exposes `getProductService()` +
304+
`getAllActivities()`.
305+
- **`RestClient`** (`src/internal/acme/rest-client.ts`) — the CNG REST client
306+
cloned with `extendableSlug = acme_backoffice_api@v1` (note the `@v1`
307+
separator, unlike CNG's `-v1`), logging `"Making ACME request"` and throwing
308+
`AcmeApiError` on non-2xx.
309+
- **Products triad** (`src/internal/acme/products/`) — `product-queries.ts`
310+
(raw `TemplateNode`/`TemplatesResponse` for the `{ list: [...] }` envelope,
311+
plus the `PUBLISHED_REVIEW_STATE` constant, internal), `product-converter.ts`
312+
(pure `fromTemplateNodes``AcmeActivity`, **filtering to published
313+
templates only** and mapping `colorCategory.backgroundColor``color`),
314+
`product-service.ts` (`AcmeProductService.getAllActivities()`, tolerating a
315+
`{ list: [...] }` envelope or a bare array). Endpoint segments live in
316+
`src/internal/acme/endpoints.ts` — the template-names path
317+
`v2/b2b/event/templates/names?pageSize=-1&page=1`.
318+
- **Model** `src/models/acme/product.ts``AcmeActivity`/`AcmeActivityTicket`,
319+
mirroring the CNG `Activity` shape. ACME exposes no tickets today, so
320+
`tickets` is always empty; only `id`/`name`/`color` are populated.
321+
- **Public exports:** `AcmeAccessService` + `AcmeAccessServiceConfig`,
322+
`AcmeProductService`, the `AcmeActivity`/`AcmeActivityTicket` types, and
323+
`AcmeApiError` (added to the errors export). Distinct type names avoid the
324+
collision with CNG's `Activity`. REST paths and raw response interfaces stay
325+
internal.
326+
290327
### 6. UI components — the `./ui` subpath
291328
`src/ui/`
292329

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@peektravel/app-utilities",
3-
"version": "0.3.1",
3+
"version": "0.4.0",
44
"description": "GraphQL JS mapping utilities extracted from the Peek Pro Autopilot connector.",
55
"license": "MIT",
66
"repository": {

src/acme-access-service.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Authenticated root entry point to the ACME backoffice REST gateway.
3+
*
4+
* The ACME sibling of {@link PeekAccessService} / {@link CngAccessService}.
5+
* Configure one instance per install; it owns the shared, authenticated
6+
* transport (minting/caching tokens on demand) and hands out the
7+
* {@link AcmeProductService}.
8+
*
9+
* Auth and transport mirror the CNG access service: the same app JWT
10+
* (`X-Peek-Auth: Bearer`) minted from the Peek app credentials via the shared
11+
* {@link TokenManager}, routed through the app-registry installations API. The
12+
* only differences are the extendable slug (`acme_backoffice_api@v1`), REST
13+
* rather than GraphQL, and no `pk-api-key` header.
14+
*/
15+
import {
16+
createTokenManager,
17+
requireNonEmpty,
18+
DEFAULT_RETRY_DELAYS_MS,
19+
type BaseAccessServiceConfig,
20+
} from "./access-service-config.js";
21+
import { ACME_EXTENDABLE_SLUG } from "./internal/acme/endpoints.js";
22+
import { AcmeProductService } from "./internal/acme/products/product-service.js";
23+
import { RestClient } from "./internal/acme/rest-client.js";
24+
import { noopLogger } from "./logger.js";
25+
26+
/** Default gateway base URL — the app-registry installations API. */
27+
const DEFAULT_BASE_URL = "https://app-registry.peeklabs.com/installations-api";
28+
29+
/**
30+
* Configuration for an {@link AcmeAccessService} instance. ACME adds no fields
31+
* beyond {@link BaseAccessServiceConfig} — it authenticates on the app JWT
32+
* alone (no `pk-api-key`, so no `gatewayKey`).
33+
*/
34+
export type AcmeAccessServiceConfig = BaseAccessServiceConfig;
35+
36+
/**
37+
* Authenticated root entry point to the ACME backoffice REST gateway.
38+
*
39+
* @example
40+
* ```ts
41+
* import { AcmeAccessService, type AcmeActivity } from "@peektravel/app-utilities";
42+
*
43+
* const acme = new AcmeAccessService({
44+
* installId: "install-123",
45+
* jwtSecret: process.env.PEEK_APP_SECRET!,
46+
* issuer: process.env.PEEK_APP_ID!,
47+
* appId: process.env.PEEK_APP_ID!,
48+
* });
49+
*
50+
* const activities: AcmeActivity[] = await acme.getAllActivities();
51+
* ```
52+
*
53+
* @throws {Error} from the constructor when any required config field
54+
* (`installId`, `jwtSecret`, `issuer`, `appId`) is empty.
55+
*/
56+
export class AcmeAccessService {
57+
private readonly client: RestClient;
58+
private productService?: AcmeProductService;
59+
60+
constructor(config: AcmeAccessServiceConfig) {
61+
requireNonEmpty(config.installId, "installId", "AcmeAccessService");
62+
requireNonEmpty(config.jwtSecret, "jwtSecret", "AcmeAccessService");
63+
requireNonEmpty(config.issuer, "issuer", "AcmeAccessService");
64+
requireNonEmpty(config.appId, "appId", "AcmeAccessService");
65+
66+
const logger = config.logger ?? noopLogger;
67+
const tokens = createTokenManager(config);
68+
69+
this.client = new RestClient({
70+
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
71+
appId: config.appId,
72+
extendableSlug: ACME_EXTENDABLE_SLUG,
73+
getToken: () => tokens.getToken(),
74+
retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
75+
logger,
76+
fetchFn: config.fetch ?? globalThis.fetch,
77+
});
78+
}
79+
80+
/**
81+
* Returns the {@link AcmeProductService} for this install, bound to the shared
82+
* authenticated transport. The instance is created lazily and reused.
83+
*/
84+
getProductService(): AcmeProductService {
85+
if (!this.productService) {
86+
this.productService = new AcmeProductService(this.client);
87+
}
88+
return this.productService;
89+
}
90+
91+
/** All activities. Delegates to {@link AcmeProductService.getAllActivities}. */
92+
getAllActivities() {
93+
return this.getProductService().getAllActivities();
94+
}
95+
}

src/errors.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* GraphQL gateway (or the CNG REST gateway) so callers can branch on the error
44
* type rather than parsing messages.
55
*
6-
* `AdminAccountRequiredError` and `RateLimitError` are shared by both gateways.
7-
* `PeekGraphQLError` is Peek-only; `CngApiError` is CNG-only.
6+
* `AdminAccountRequiredError` and `RateLimitError` are shared by all gateways.
7+
* `PeekGraphQLError` is Peek-only; `CngApiError` is CNG-only; `AcmeApiError` is
8+
* ACME-only.
89
*/
910

1011
/**
@@ -69,3 +70,24 @@ export class CngApiError extends Error {
6970
this.body = body;
7071
}
7172
}
73+
74+
/**
75+
* Thrown when the ACME REST gateway returns a non-2xx response that is not one
76+
* of the specifically-handled statuses (418/429). The offending status is
77+
* preserved on {@link AcmeApiError.statusCode}, and the raw response body
78+
* (parsed JSON when possible, otherwise the raw text) on
79+
* {@link AcmeApiError.body}.
80+
*/
81+
export class AcmeApiError extends Error {
82+
/** The HTTP status that triggered this error. */
83+
public readonly statusCode: number;
84+
/** The raw response body (parsed JSON when possible, otherwise text). */
85+
public readonly body: unknown;
86+
87+
constructor(statusCode: number, body: unknown, message?: string) {
88+
super(message ?? `ACME request failed with HTTP ${statusCode}`);
89+
this.name = "AcmeApiError";
90+
this.statusCode = statusCode;
91+
this.body = body;
92+
}
93+
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export type { CngAccessServiceConfig } from "./cng-access-service.js";
1414
export { CngProductService } from "./internal/cng/products/product-service.js";
1515
export type { Activity, ActivityTicket } from "./models/cng/product.js";
1616

17+
// ─── ACME (REST) — sibling accessor sharing this package's auth/transport/UI ─
18+
export { AcmeAccessService } from "./acme-access-service.js";
19+
export type { AcmeAccessServiceConfig } from "./acme-access-service.js";
20+
export { AcmeProductService } from "./internal/acme/products/product-service.js";
21+
export type { AcmeActivity, AcmeActivityTicket } from "./models/acme/product.js";
22+
1723
export { ProductService } from "./internal/peek/products/product-service.js";
1824
export type { ProductServiceOptions } from "./internal/peek/products/product-service.js";
1925

@@ -131,6 +137,7 @@ export { noopLogger } from "./logger.js";
131137
export type { Logger } from "./logger.js";
132138

133139
export {
140+
AcmeApiError,
134141
AdminAccountRequiredError,
135142
CngApiError,
136143
PeekGraphQLError,

src/internal/acme/endpoints.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Path segments for the ACME backoffice REST gateway. Shared across the ACME
3+
* resources so each value lives in exactly one place. (The CNG gateway segments
4+
* live separately in `../cng/endpoints.ts`.)
5+
*/
6+
7+
/**
8+
* Fixed extendable slug inserted between `appId` and the REST path. This is the
9+
* only routing difference from the other gateways (CNG uses
10+
* `cng_backoffice_api-v1`; note ACME's `@v1` separator).
11+
*/
12+
export const ACME_EXTENDABLE_SLUG = "acme_backoffice_api@v1";
13+
14+
/** REST path (relative to the extendable) for the event-template names list. */
15+
export const TEMPLATES_PATH = "v2/b2b/event/templates/names?pageSize=-1&page=1";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Pure functions that map raw ACME template nodes into the clean
3+
* {@link AcmeActivity} model. No I/O — straightforward, testable
4+
* transformations.
5+
*
6+
* Only `"published"` templates are surfaced; ACME does not expose tickets, so
7+
* every activity carries an empty `tickets` list.
8+
*/
9+
import { ACME_ACTIVITY_TYPE, type AcmeActivity } from "../../../models/acme/product.js";
10+
import { PUBLISHED_REVIEW_STATE, type TemplateNode } from "./product-queries.js";
11+
12+
/**
13+
* Converts a list of raw template nodes into {@link AcmeActivity}s, keeping only
14+
* published templates.
15+
*/
16+
export function fromTemplateNodes(nodes: TemplateNode[]): AcmeActivity[] {
17+
return nodes
18+
.filter((node) => node.reviewState === PUBLISHED_REVIEW_STATE)
19+
.map(fromTemplateNode);
20+
}
21+
22+
/** Converts a single raw template node into an {@link AcmeActivity}. */
23+
function fromTemplateNode(node: TemplateNode): AcmeActivity {
24+
return {
25+
productId: node.id || "",
26+
name: node.name || "",
27+
type: node.type || ACME_ACTIVITY_TYPE,
28+
color: node.colorCategory?.backgroundColor || "#d1d1d1",
29+
tickets: [],
30+
};
31+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Raw ACME REST response shapes for event templates. Internal implementation
3+
* detail of the package — deliberately not re-exported from the public entry
4+
* point.
5+
*
6+
* The `v2/b2b/event/templates/names` endpoint returns a `{ list: [...] }`
7+
* envelope. Each node carries a `reviewState`; only `"published"` templates are
8+
* surfaced as activities (see the converter).
9+
*/
10+
11+
/** The published review state — the only templates surfaced as activities. */
12+
export const PUBLISHED_REVIEW_STATE = "published";
13+
14+
/** A single template node as returned by the templates/names endpoint. */
15+
export interface TemplateNode {
16+
/** Stable template id. */
17+
id: string;
18+
/** Display name. */
19+
name: string;
20+
/** Template type (e.g. `"standard"`). */
21+
type?: string;
22+
/** Admission type (e.g. `"standard"`). */
23+
admissionType?: string;
24+
/** Publication state; only `"published"` templates are surfaced. */
25+
reviewState?: string;
26+
/** Display color pair, if set. */
27+
colorCategory?: {
28+
backgroundColor?: string | null;
29+
textColor?: string | null;
30+
} | null;
31+
}
32+
33+
/**
34+
* The templates list payload. Tolerates either a top-level `list` array or a
35+
* bare array response.
36+
*/
37+
export interface TemplatesResponse {
38+
list?: TemplateNode[];
39+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Product-related operations against the ACME gateway.
3+
*
4+
* Obtain an instance via {@link AcmeAccessService.getProductService} rather than
5+
* constructing it directly — the access service wires in the authenticated,
6+
* shared transport. This class is where ACME product-specific business logic
7+
* lives. Named `AcmeProductService` to disambiguate from the Peek
8+
* `ProductService` and the CNG `CngProductService` in the same package.
9+
*/
10+
import { TEMPLATES_PATH } from "../endpoints.js";
11+
import type { RestClient } from "../rest-client.js";
12+
import type { AcmeActivity } from "../../../models/acme/product.js";
13+
import { fromTemplateNodes } from "./product-converter.js";
14+
import type { TemplatesResponse } from "./product-queries.js";
15+
16+
export class AcmeProductService {
17+
constructor(private readonly client: RestClient) {}
18+
19+
/**
20+
* Returns every published event template as a single flat list of activities.
21+
*
22+
* @example
23+
* ```ts
24+
* const activities = await acme.getProductService().getAllActivities();
25+
* ```
26+
*/
27+
async getAllActivities(): Promise<AcmeActivity[]> {
28+
const body = await this.client.get<TemplatesResponse | TemplatesResponse["list"]>(
29+
TEMPLATES_PATH,
30+
);
31+
// Tolerate either a { list: [...] } envelope or a bare array.
32+
const nodes = Array.isArray(body) ? body : (body?.list ?? []);
33+
return fromTemplateNodes(nodes ?? []);
34+
}
35+
}

0 commit comments

Comments
 (0)