@inspira-npm/events 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,141 @@
1
+ import { t as AdClickRecordPayload } from "./click-record-B8KyALZZ.js";
2
+ import { z } from "zod";
3
+ /**
4
+ * IP del cliente desde los headers del request: `cf-connecting-ip`, `true-client-ip`, el primer
5
+ * valor de `x-forwarded-for` y `x-real-ip`, en ese orden. Salta los valores vacíos, inválidos o
6
+ * de relleno (`0`, `::1`, `127.0.0.1`…). Devuelve null si ninguno sirve.
7
+ */
8
+ export declare function resolveClientIp(headers: Headers): string | null;
9
+ /** Datos del usuario logueado, desde la sesión del sitio. Un campo ausente o vacío viaja `null`. */
10
+ interface AdClickUser {
11
+ /** Id del usuario en Inspira. */
12
+ inspira_id?: string | number | null;
13
+ email?: string | null;
14
+ phone?: string | null;
15
+ /** Id del usuario en el sistema del cliente white-label. */
16
+ external_id?: string | number | null;
17
+ loyalty_id?: string | number | null;
18
+ }
19
+ /** Body de `POST {apiBase}/api/v1/ad-clicks` (ver docs/protocol.md). */
20
+ interface AdClickRecordBody {
21
+ click_id: string;
22
+ ad_id: string;
23
+ token?: string;
24
+ placement: string;
25
+ position?: number;
26
+ page_url: string;
27
+ /** `ts` del navegador. */
28
+ client_ts?: string;
29
+ /** Hora del servidor del sitio al recibir el click. */
30
+ clicked_at: string;
31
+ org: number | null;
32
+ ip: string | null;
33
+ user_agent: string | null;
34
+ user: {
35
+ inspira_id: string | null;
36
+ email: string | null;
37
+ phone: string | null;
38
+ external_id: string | null;
39
+ loyalty_id: string | null;
40
+ } | null;
41
+ }
42
+ /** Credenciales y red para hablar con Laravel (`inspira-promotions-laravel`). */
43
+ interface AdClickApiOptions {
44
+ /** URL base de Laravel, p. ej. `https://inspirapromotions.com` (sin `/api`). */
45
+ apiBase: string;
46
+ /** Cliente OAuth2 (client_credentials, scope `ad-clicks:write`) dado de alta en el admin. */
47
+ clientId: string;
48
+ /** Secreto del cliente. Solo servidor: nunca en una variable pública ni en el bundle. */
49
+ clientSecret: string;
50
+ /** Tiempo máximo TOTAL del envío (token incluido). Default 3000 ms. */
51
+ timeoutMs?: number;
52
+ /** Reemplaza `fetch` (tests). Default: el `fetch` global. */
53
+ fetch?: typeof fetch;
54
+ }
55
+ interface RecordAdClickOptions extends AdClickApiOptions {
56
+ /** Body JSON tal como lo mandó el navegador; se valida aquí. */
57
+ payload: unknown;
58
+ /** Usuario logueado, o null/omitido si no hay sesión. */
59
+ user?: AdClickUser | null;
60
+ /** Organización (whitelabel) del tenant, o null. */
61
+ org?: number | null;
62
+ ip?: string | null;
63
+ userAgent?: string | null;
64
+ /** Hora del click en el servidor. Default: ahora. */
65
+ clickedAt?: Date;
66
+ }
67
+ type RecordAdClickFailureReason =
68
+ /** El body del navegador no cumple el contrato (`error` = ZodError). */
69
+ "invalid_payload" |
70
+ /** Falta `apiBase`, `clientId` o `clientSecret`. */
71
+ "not_configured" |
72
+ /** `POST /oauth/token` respondió con error (credenciales o scope). */
73
+ "token_failed" |
74
+ /** 401 aun con un token nuevo. */
75
+ "unauthorized" |
76
+ /** 403: el cliente no tiene el scope `ad-clicks:write`. */
77
+ "forbidden" |
78
+ /** 422: Laravel rechazó el body. */
79
+ "rejected" |
80
+ /** Otro status de error (5xx, 429…). */
81
+ "http_error" | "timeout" | "network_error";
82
+ interface RecordAdClickFailure {
83
+ ok: false;
84
+ reason: RecordAdClickFailureReason;
85
+ /** Status HTTP de la respuesta que falló, si hubo respuesta. */
86
+ httpStatus?: number;
87
+ error?: unknown;
88
+ }
89
+ type RecordAdClickResult = {
90
+ ok: true;
91
+ status: "recorded" | "duplicate";
92
+ httpStatus: number;
93
+ } | RecordAdClickFailure;
94
+ /**
95
+ * Registra un click en Laravel (bajo nivel; `createAdClickRoute` lo usa por dentro): valida el
96
+ * body del navegador, arma el body con usuario, organización, IP y user agent, y lo envía con un
97
+ * token client_credentials cacheado. Nunca lanza.
98
+ */
99
+ export declare function recordAdClick(options: RecordAdClickOptions): Promise<RecordAdClickResult>;
100
+ type MaybePromise<T> = T | Promise<T>;
101
+ interface AdClickRouteErrorInfo {
102
+ /** Dónde falló: la sesión (`get_user`), el tenant (`get_org`) o el envío a Laravel (`record`). */
103
+ stage: "get_user" | "get_org" | "record";
104
+ click_id: string;
105
+ /** Solo en `record`: el resultado del envío. */
106
+ result?: RecordAdClickFailure;
107
+ }
108
+ interface AdClickRouteOptions extends AdClickApiOptions {
109
+ /**
110
+ * Usuario logueado desde la sesión del SITIO (cookie del mismo origen), o null si no hay
111
+ * sesión. Si lanza, el click se registra igual con `user: null` (y se avisa a `onError`).
112
+ */
113
+ getUser: (request: Request) => MaybePromise<AdClickUser | null | undefined>;
114
+ /** Organización (whitelabel) del tenant, o null. Si lanza, `org: null`. */
115
+ getOrg: (request: Request) => MaybePromise<number | null | undefined>;
116
+ /** IP del cliente. Default: `resolveClientIp(request.headers)`. */
117
+ getIp?: (request: Request) => string | null | undefined;
118
+ /**
119
+ * Errores que no llegan al navegador (siempre recibe 204): sesión, tenant y envío a Laravel.
120
+ * Nunca recibe datos del usuario.
121
+ */
122
+ onError?: (error: unknown, info: AdClickRouteErrorInfo) => void;
123
+ /**
124
+ * Para responder sin esperar a Laravel: recibe la promesa del envío y debe mantener viva la
125
+ * función hasta que termine (`after` de `next/server`, `waitUntil` de Vercel o del runtime
126
+ * edge). Sin esto, la ruta espera el envío (como máximo `timeoutMs`) antes de responder.
127
+ */
128
+ waitUntil?: (task: Promise<unknown>) => void;
129
+ /** Tamaño máximo del body. Default 8 KB. */
130
+ maxBodyBytes?: number;
131
+ }
132
+ /**
133
+ * Handler de la ruta del sitio que recibe el registro de clicks del SDK (`clickRecord`) y lo
134
+ * reenvía a Laravel con la identidad de la sesión. Para Next.js: `export const POST =
135
+ * createAdClickRoute({ … })`. Usa solo `Request`/`Response`/`fetch` estándar (Node ≥ 18, edge).
136
+ *
137
+ * Respuestas al navegador: 204 (registro aceptado, aunque Laravel falle: se avisa a `onError`),
138
+ * 400 body inválido, 403 request de otro sitio, 405 método distinto de POST, 413 body grande.
139
+ */
140
+ export declare function createAdClickRoute(options: AdClickRouteOptions): (request: Request) => Promise<Response>;
141
+ export type { AdClickApiOptions, AdClickRecordBody, AdClickRecordPayload, AdClickRouteErrorInfo, AdClickRouteOptions, AdClickUser, RecordAdClickFailure, RecordAdClickFailureReason, RecordAdClickOptions, RecordAdClickResult };
package/dist/server.js ADDED
@@ -0,0 +1,445 @@
1
+ import { r as placement } from "./shared-D2s2QSYg.js";
2
+ import { z } from "zod";
3
+ // src/server/ip.ts
4
+ const IP_HEADERS = [
5
+ "cf-connecting-ip",
6
+ "true-client-ip",
7
+ "x-forwarded-for",
8
+ "x-real-ip"
9
+ ];
10
+ const PLACEHOLDERS = /* @__PURE__ */ new Set([
11
+ "0",
12
+ "0.0.0.0",
13
+ "::",
14
+ "::1",
15
+ "127.0.0.1",
16
+ "::ffff:127.0.0.1"
17
+ ]);
18
+ const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
19
+ const HEX_GROUP = /^[0-9a-f]{1,4}$/;
20
+ function isIPv4(value) {
21
+ return IPV4.test(value);
22
+ }
23
+ function isIPv6(value) {
24
+ if (value.length > 45 || !value.includes(":")) return false;
25
+ let address = value;
26
+ const lastColon = address.lastIndexOf(":");
27
+ const tail = address.slice(lastColon + 1);
28
+ if (tail.includes(".")) {
29
+ if (!isIPv4(tail)) return false;
30
+ address = `${address.slice(0, lastColon + 1)}0:0`;
31
+ }
32
+ const halves = address.split("::");
33
+ if (halves.length > 2) return false;
34
+ const groups = (part) => part ? part.split(":") : [];
35
+ const head = groups(halves[0]);
36
+ const rest = groups(halves[1]);
37
+ if (![...head, ...rest].every((group) => HEX_GROUP.test(group))) return false;
38
+ const count = head.length + rest.length;
39
+ return halves.length === 2 ? count < 8 : count === 8;
40
+ }
41
+ /**
42
+ * Normaliza un valor de header a una IP válida, o null. Acepta `[v6]:puerto` y `v4:puerto`
43
+ * (algunos balanceadores agregan el puerto).
44
+ */
45
+ function normalizeIp(raw) {
46
+ let value = raw.trim().toLowerCase();
47
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(value);
48
+ if (bracketed?.[1]) value = bracketed[1];
49
+ else if (/^[\d.]+:\d+$/.test(value)) value = value.slice(0, value.lastIndexOf(":"));
50
+ if (PLACEHOLDERS.has(value)) return null;
51
+ return isIPv4(value) || isIPv6(value) ? value : null;
52
+ }
53
+ /**
54
+ * IP del cliente desde los headers del request: `cf-connecting-ip`, `true-client-ip`, el primer
55
+ * valor de `x-forwarded-for` y `x-real-ip`, en ese orden. Salta los valores vacíos, inválidos o
56
+ * de relleno (`0`, `::1`, `127.0.0.1`…). Devuelve null si ninguno sirve.
57
+ */
58
+ function resolveClientIp(headers) {
59
+ for (const name of IP_HEADERS) {
60
+ const raw = headers.get(name);
61
+ if (!raw) continue;
62
+ const ip = normalizeIp(name === "x-forwarded-for" ? raw.split(",")[0] ?? "" : raw);
63
+ if (ip) return ip;
64
+ }
65
+ return null;
66
+ }
67
+ // src/server/record.ts
68
+ /**
69
+ * Body del navegador (`AdClickRecordPayload`). Los campos desconocidos se descartan: solo viaja a
70
+ * Laravel lo que este esquema conoce.
71
+ */
72
+ const adClickRecordPayloadSchema = z.object({
73
+ v: z.literal(1),
74
+ click_id: z.uuid(),
75
+ ad_id: z.string().min(1).max(100),
76
+ token: z.string().max(2e3).optional(),
77
+ placement,
78
+ position: z.int().nonnegative().optional(),
79
+ page_url: z.string().min(1).max(2e3).regex(/^https?:\/\//i, "page_url: URL http(s)"),
80
+ ts: z.iso.datetime({ offset: true })
81
+ });
82
+ const SCOPE = "ad-clicks:write";
83
+ const DEFAULT_TIMEOUT_MS = 3e3;
84
+ const MAX_USER_AGENT_LENGTH = 500;
85
+ const DEFAULT_TOKEN_TTL_S = 3600;
86
+ function parseAdClickPayload(input) {
87
+ return adClickRecordPayloadSchema.safeParse(input);
88
+ }
89
+ function text(value) {
90
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
91
+ if (typeof value !== "string") return null;
92
+ const trimmed = value.trim();
93
+ return trimmed === "" ? null : trimmed;
94
+ }
95
+ function normalizeUser(user) {
96
+ if (!user) return null;
97
+ return {
98
+ inspira_id: text(user.inspira_id),
99
+ email: text(user.email),
100
+ phone: text(user.phone),
101
+ external_id: text(user.external_id),
102
+ loyalty_id: text(user.loyalty_id)
103
+ };
104
+ }
105
+ /** Arma el body para Laravel con un payload ya validado y el contexto del servidor. */
106
+ function buildAdClickBody(payload, context) {
107
+ const userAgent = text(context.userAgent)?.slice(0, MAX_USER_AGENT_LENGTH) ?? null;
108
+ return {
109
+ click_id: payload.click_id,
110
+ ad_id: payload.ad_id,
111
+ token: payload.token,
112
+ placement: payload.placement,
113
+ position: payload.position,
114
+ page_url: payload.page_url,
115
+ client_ts: payload.ts,
116
+ clicked_at: (context.clickedAt ?? /* @__PURE__ */ new Date()).toISOString(),
117
+ org: Number.isInteger(context.org) ? context.org : null,
118
+ ip: typeof context.ip === "string" ? normalizeIp(context.ip) : null,
119
+ user_agent: userAgent,
120
+ user: normalizeUser(context.user)
121
+ };
122
+ }
123
+ const tokens = /* @__PURE__ */ new Map();
124
+ const pendingTokens = /* @__PURE__ */ new Map();
125
+ var TokenError = class extends Error {
126
+ constructor(httpStatus) {
127
+ super(`oauth/token respondió ${httpStatus}`);
128
+ this.httpStatus = httpStatus;
129
+ }
130
+ };
131
+ const baseUrl = (apiBase) => apiBase.replace(/\/+$/, "");
132
+ const cacheKey = (options) => `${baseUrl(options.apiBase)}\n${options.clientId}`;
133
+ async function requestToken(options, doFetch, signal) {
134
+ const response = await doFetch(`${baseUrl(options.apiBase)}/oauth/token`, {
135
+ method: "POST",
136
+ headers: {
137
+ "Content-Type": "application/x-www-form-urlencoded",
138
+ Accept: "application/json"
139
+ },
140
+ body: new URLSearchParams({
141
+ grant_type: "client_credentials",
142
+ client_id: options.clientId,
143
+ client_secret: options.clientSecret,
144
+ scope: SCOPE
145
+ }).toString(),
146
+ signal
147
+ });
148
+ if (!response.ok) {
149
+ await discard(response);
150
+ throw new TokenError(response.status);
151
+ }
152
+ const data = await response.json();
153
+ if (typeof data.access_token !== "string" || data.access_token === "") throw new TokenError(response.status);
154
+ const ttl = typeof data.expires_in === "number" && data.expires_in > 0 ? data.expires_in : DEFAULT_TOKEN_TTL_S;
155
+ const margin = Math.min(60, ttl / 10);
156
+ return {
157
+ token: data.access_token,
158
+ refreshAt: Date.now() + (ttl - margin) * 1e3
159
+ };
160
+ }
161
+ function untilAborted(promise, signal) {
162
+ if (signal.aborted) return Promise.reject(signal.reason);
163
+ return new Promise((resolve, reject) => {
164
+ const onAbort = () => reject(signal.reason);
165
+ signal.addEventListener("abort", onAbort, { once: true });
166
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
167
+ });
168
+ }
169
+ async function accessToken(options, doFetch, signal) {
170
+ const key = cacheKey(options);
171
+ const cached = tokens.get(key);
172
+ if (cached && Date.now() < cached.refreshAt) return cached.token;
173
+ let pending = pendingTokens.get(key);
174
+ if (!pending) {
175
+ pending = requestToken(options, doFetch, signal).then((fresh) => {
176
+ tokens.set(key, fresh);
177
+ pendingTokens.delete(key);
178
+ return fresh;
179
+ }, (error) => {
180
+ pendingTokens.delete(key);
181
+ throw error;
182
+ });
183
+ pendingTokens.set(key, pending);
184
+ }
185
+ return (await untilAborted(pending, signal)).token;
186
+ }
187
+ function forgetToken(options, token) {
188
+ const key = cacheKey(options);
189
+ if (tokens.get(key)?.token === token) tokens.delete(key);
190
+ }
191
+ async function discard(response) {
192
+ try {
193
+ await response.text();
194
+ } catch {}
195
+ }
196
+ function failureFor(status) {
197
+ return {
198
+ ok: false,
199
+ reason: status === 401 ? "unauthorized" : status === 403 ? "forbidden" : status === 422 ? "rejected" : "http_error",
200
+ httpStatus: status
201
+ };
202
+ }
203
+ /**
204
+ * Envía un body ya armado a `POST {apiBase}/api/v1/ad-clicks`. Pide (y cachea) el token; ante un
205
+ * 401 lo renueva y reintenta una vez. Nunca lanza: los errores vuelven como `ok: false`.
206
+ */
207
+ async function deliverAdClick(body, options) {
208
+ if (!options.apiBase || !options.clientId || !options.clientSecret) return {
209
+ ok: false,
210
+ reason: "not_configured"
211
+ };
212
+ const doFetch = options.fetch ?? globalThis.fetch;
213
+ const controller = new AbortController();
214
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("ad click record: timeout")), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
215
+ try {
216
+ for (let attempt = 1;; attempt++) {
217
+ const token = await accessToken(options, doFetch, controller.signal);
218
+ const response = await doFetch(`${baseUrl(options.apiBase)}/api/v1/ad-clicks`, {
219
+ method: "POST",
220
+ headers: {
221
+ Authorization: `Bearer ${token}`,
222
+ "Content-Type": "application/json",
223
+ Accept: "application/json"
224
+ },
225
+ body: JSON.stringify(body),
226
+ signal: controller.signal
227
+ });
228
+ if (response.status === 401 && attempt === 1) {
229
+ await discard(response);
230
+ forgetToken(options, token);
231
+ continue;
232
+ }
233
+ if (!response.ok) {
234
+ await discard(response);
235
+ return failureFor(response.status);
236
+ }
237
+ let status;
238
+ try {
239
+ status = (await response.json())?.status;
240
+ } catch {
241
+ status = void 0;
242
+ }
243
+ return {
244
+ ok: true,
245
+ status: status === "duplicate" || status !== "recorded" && response.status === 200 ? "duplicate" : "recorded",
246
+ httpStatus: response.status
247
+ };
248
+ }
249
+ } catch (error) {
250
+ if (controller.signal.aborted) return {
251
+ ok: false,
252
+ reason: "timeout",
253
+ error
254
+ };
255
+ if (error instanceof TokenError) return {
256
+ ok: false,
257
+ reason: "token_failed",
258
+ httpStatus: error.httpStatus,
259
+ error
260
+ };
261
+ return {
262
+ ok: false,
263
+ reason: "network_error",
264
+ error
265
+ };
266
+ } finally {
267
+ clearTimeout(timer);
268
+ }
269
+ }
270
+ /**
271
+ * Registra un click en Laravel (bajo nivel; `createAdClickRoute` lo usa por dentro): valida el
272
+ * body del navegador, arma el body con usuario, organización, IP y user agent, y lo envía con un
273
+ * token client_credentials cacheado. Nunca lanza.
274
+ */
275
+ async function recordAdClick(options) {
276
+ try {
277
+ const parsed = parseAdClickPayload(options.payload);
278
+ if (!parsed.success) return {
279
+ ok: false,
280
+ reason: "invalid_payload",
281
+ error: parsed.error
282
+ };
283
+ return await deliverAdClick(buildAdClickBody(parsed.data, options), options);
284
+ } catch (error) {
285
+ return {
286
+ ok: false,
287
+ reason: "network_error",
288
+ error
289
+ };
290
+ }
291
+ }
292
+ // src/server/route.ts
293
+ const DEFAULT_MAX_BODY_BYTES = 8192;
294
+ const NO_STORE = { "Cache-Control": "no-store" };
295
+ function reply(status, error, headers = {}) {
296
+ if (!error) return new Response(null, {
297
+ status,
298
+ headers: {
299
+ ...NO_STORE,
300
+ ...headers
301
+ }
302
+ });
303
+ return new Response(JSON.stringify({ error }), {
304
+ status,
305
+ headers: {
306
+ ...NO_STORE,
307
+ "Content-Type": "application/json",
308
+ ...headers
309
+ }
310
+ });
311
+ }
312
+ const firstValue = (value) => value?.split(",")[0]?.trim().toLowerCase() || null;
313
+ function requestHosts(request) {
314
+ const hosts = [firstValue(request.headers.get("host"))];
315
+ hosts.push(firstValue(request.headers.get("x-forwarded-host")));
316
+ try {
317
+ hosts.push(new URL(request.url).host.toLowerCase());
318
+ } catch {}
319
+ return hosts.filter((host) => !!host);
320
+ }
321
+ /**
322
+ * Solo requests del propio sitio: la ruta usa la cookie de sesión, así que un POST desde otro
323
+ * sitio (CSRF) registraría clicks a nombre del visitante.
324
+ * - Con `Sec-Fetch-Site` (navegadores actuales; el navegador lo calcula y una página no lo puede
325
+ * falsear) solo se acepta `same-origin`.
326
+ * - Sin él, el `Origin` (si viene) debe tener el mismo host que el request. Sin ninguno de los dos
327
+ * no es un navegador actual: no hay cookie de un visitante que proteger.
328
+ */
329
+ function isSameOrigin(request) {
330
+ const site = request.headers.get("sec-fetch-site");
331
+ if (site !== null) return site === "same-origin";
332
+ const origin = request.headers.get("origin");
333
+ if (origin === null) return true;
334
+ let host;
335
+ try {
336
+ host = new URL(origin).host.toLowerCase();
337
+ } catch {
338
+ return false;
339
+ }
340
+ return host !== "" && requestHosts(request).includes(host);
341
+ }
342
+ var BodyTooLarge = class extends Error {};
343
+ async function readBody(request, max) {
344
+ const declared = Number(request.headers.get("content-length") ?? "");
345
+ if (Number.isFinite(declared) && declared > max) throw new BodyTooLarge();
346
+ if (!request.body) return "";
347
+ const reader = request.body.getReader();
348
+ const chunks = [];
349
+ let size = 0;
350
+ for (;;) {
351
+ const { done, value } = await reader.read();
352
+ if (done) break;
353
+ size += value.byteLength;
354
+ if (size > max) {
355
+ reader.cancel().catch(() => {});
356
+ throw new BodyTooLarge();
357
+ }
358
+ chunks.push(value);
359
+ }
360
+ const bytes = new Uint8Array(size);
361
+ let offset = 0;
362
+ for (const chunk of chunks) {
363
+ bytes.set(chunk, offset);
364
+ offset += chunk.byteLength;
365
+ }
366
+ return new TextDecoder().decode(bytes);
367
+ }
368
+ /**
369
+ * Handler de la ruta del sitio que recibe el registro de clicks del SDK (`clickRecord`) y lo
370
+ * reenvía a Laravel con la identidad de la sesión. Para Next.js: `export const POST =
371
+ * createAdClickRoute({ … })`. Usa solo `Request`/`Response`/`fetch` estándar (Node ≥ 18, edge).
372
+ *
373
+ * Respuestas al navegador: 204 (registro aceptado, aunque Laravel falle: se avisa a `onError`),
374
+ * 400 body inválido, 403 request de otro sitio, 405 método distinto de POST, 413 body grande.
375
+ */
376
+ function createAdClickRoute(options) {
377
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
378
+ const report = (error, info) => {
379
+ try {
380
+ options.onError?.(error, info);
381
+ } catch {}
382
+ };
383
+ return async function adClickRoute(request) {
384
+ if (request.method !== "POST") return reply(405, void 0, { Allow: "POST" });
385
+ if (!isSameOrigin(request)) return reply(403, "cross_site");
386
+ let input;
387
+ try {
388
+ input = JSON.parse(await readBody(request, maxBodyBytes));
389
+ } catch (error) {
390
+ if (error instanceof BodyTooLarge) return reply(413, "payload_too_large");
391
+ return reply(400, "invalid_payload");
392
+ }
393
+ const parsed = parseAdClickPayload(input);
394
+ if (!parsed.success) return reply(400, "invalid_payload");
395
+ const payload = parsed.data;
396
+ const clickId = payload.click_id;
397
+ let user = null;
398
+ try {
399
+ user = await options.getUser(request) ?? null;
400
+ } catch (error) {
401
+ report(error, {
402
+ stage: "get_user",
403
+ click_id: clickId
404
+ });
405
+ }
406
+ let org = null;
407
+ try {
408
+ org = await options.getOrg(request) ?? null;
409
+ } catch (error) {
410
+ report(error, {
411
+ stage: "get_org",
412
+ click_id: clickId
413
+ });
414
+ }
415
+ let ip = null;
416
+ try {
417
+ ip = options.getIp ? options.getIp(request) ?? null : resolveClientIp(request.headers);
418
+ } catch {
419
+ ip = null;
420
+ }
421
+ const task = deliverAdClick(buildAdClickBody(payload, {
422
+ user,
423
+ org,
424
+ ip,
425
+ userAgent: request.headers.get("user-agent")
426
+ }), options).then((result) => {
427
+ if (!result.ok) {
428
+ const error = result.error ?? /* @__PURE__ */ new Error(`ad click record: ${result.reason}`);
429
+ report(error, {
430
+ stage: "record",
431
+ click_id: clickId,
432
+ result
433
+ });
434
+ }
435
+ });
436
+ if (options.waitUntil) try {
437
+ options.waitUntil(task);
438
+ } catch {
439
+ await task;
440
+ }
441
+ else await task;
442
+ return reply(204);
443
+ };
444
+ }
445
+ export { createAdClickRoute, recordAdClick, resolveClientIp };
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ // src/catalog/shared.ts
3
+ /**
4
+ * Dónde se mostró algo, como `<página>.<sección>` en minúsculas: `home.resorts-carousel`,
5
+ * `hotels-srp.side-rail`, `landing-ufc.hero`. Es la dimensión principal de los reportes:
6
+ * mantenla estable (no metas ids ni textos traducidos).
7
+ */
8
+ const placement = z.string().max(100).regex(/^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/, "placement: <pagina>.<seccion> en minúsculas").describe("Dónde se mostró: <pagina>.<seccion>, p. ej. home.resorts-carousel");
9
+ /** Posición 0-based dentro de un carrusel/lista. */
10
+ const position = z.int().nonnegative().describe("Posición 0-based en la lista o carrusel");
11
+ const itemId = z.string().min(1).max(200);
12
+ /** Servicios de búsqueda/reserva de la plataforma. */
13
+ const service = z.enum([
14
+ "hotels",
15
+ "resorts",
16
+ "flights",
17
+ "cars",
18
+ "activities",
19
+ "transfers",
20
+ "cruises"
21
+ ]).describe("Servicio de la plataforma");
22
+ const currency = z.string().regex(/^[A-Z]{3}$/, "currency: código ISO 4217 en mayúsculas").describe("Código ISO 4217, p. ej. USD");
23
+ export { service as a, position as i, itemId as n, placement as r, currency as t };
@@ -0,0 +1,4 @@
1
+ // src/version.ts
2
+ const SDK_NAME = "@inspira-npm/events";
3
+ const SDK_VERSION = "0.3.0";
4
+ export { SDK_VERSION as n, SDK_NAME as t };
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@inspira-npm/events",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "SDK de tracking de eventos de Inspira (ads, cards, mapas, búsquedas) para navegador, con helpers de servidor en /server.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "!dist/**/*.map"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./catalog": {
18
+ "types": "./dist/catalog.d.ts",
19
+ "import": "./dist/catalog.js"
20
+ },
21
+ "./server": {
22
+ "types": "./dist/server.d.ts",
23
+ "import": "./dist/server.js"
24
+ },
25
+ "./catalog.json": "./dist/catalog.json",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "types": "./dist/index.d.ts",
29
+ "scripts": {
30
+ "build": "tsdown && node scripts/build-catalog.mjs && node scripts/build-catalog-docs.mjs",
31
+ "test": "vitest run",
32
+ "typecheck": "tsc -p tsconfig.json",
33
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
34
+ },
35
+ "dependencies": {
36
+ "zod": "^4.6.7"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.