Skip to content
Open
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
21 changes: 19 additions & 2 deletions src/integrations/calcom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export function calcomTimeZone(env: Env): string {
return (env.CALCOM_TIMEZONE || "").trim() || DEFAULT_TZ;
}

/** YYYY-MM-DD de hoy en la zona dada (en-CA formatea ISO). */
export function todayInTz(timeZone: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}

/**
* Resuelve el eventTypeId para un servicio. Si hay un mapa CALCOM_EVENT_TYPES,
* busca por coincidencia de palabra (case-insensitive); si no, usa el default.
Expand Down Expand Up @@ -78,8 +88,12 @@ export async function getAvailableSlots(
const res = await fetch(url, {
headers: { Authorization: `Bearer ${env.CALCOM_API_KEY}`, "cal-api-version": SLOTS_VERSION },
});
if (!res.ok) return { ok: false, reason: `http_${res.status}` };
if (!res.ok) {
console.warn(`[calcom] slots http_${res.status}:`, (await res.text()).slice(0, 300));
return { ok: false, reason: `http_${res.status}` };
}
const body = (await res.json()) as { data?: Record<string, Slot[]> };
console.log(`[calcom] slots ok eventType=${eventTypeId} date=${date} tz=${timeZone}:`, JSON.stringify(body.data ?? {}).slice(0, 300));
Comment on lines +91 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the Cal.com API v2 /v2/bookings error response ever include the submitted attendee email or name in the error message?

💡 Result:

The Cal.com API v2 error response format does not explicitly include a dedicated field for the submitted attendee email [1][2][3]. According to the official documentation, Cal.com API v2 uses a consistent, structured error response that contains a status, code, and message [1][2][3]. The structure is as follows: { "status": "error", "error": { "code": "SOME_ERROR_CODE", "message": "A description of the error" } } While the error message string (in the "message" field) could technically contain dynamic content depending on the specific validation failure—such as missing required fields or invalid input—there is no systematic inclusion of the submitted attendee email in the standard error response schema [1][4][3]. In practice, error messages are typically generic descriptions of the failed validation or server-side issue [1][5][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file size =="
wc -l src/integrations/calcom.ts 2>/dev/null || true

echo "== relevant calcom.ts sections =="
sed -n '1,180p' src/integrations/calcom.ts

echo "== requests for calcom logging =="
rg -n "Cal.com|calcom|/v2/bookings|createBooking|getAvailableSlots|console\.log|console\.warn|HTTP_|http_|res\.text|res\.json|bookings" -S .

Repository: santmun/forja

Length of output: 37450


🌐 Web query:

Cal.com API v2 bookings validation error email name validation message

💡 Result:

When using the Cal.com API v2 to create bookings (POST /v2/bookings), validation errors—such as those involving attendee information (name, email) or other required fields—are typically returned as 400 Bad Request responses containing a structured error object [1][2]. Key details regarding validation and error messages: 1. Structured Error Format: API v2 provides structured error responses [1]. If a validation fails, the response will generally have a status of "error" and include an error code (e.g., BAD_REQUEST) along with specific details about which fields failed validation [1][2][3]. 2. Attendee Validation: - Name: Recent updates to the API have improved name validation to be more flexible, for instance by making the last name optional while still requiring a valid, non-empty first name [4]. - Contact Methods: The API requires at least one valid contact method—either an email or a phone number [5][6]. If an email is provided, it must follow a valid email format [5][6]. 3. Required Fields: If an event type has mandatory booking fields defined in its configuration, failing to provide values for these in the bookingFieldsResponses object will trigger a validation error [7]. Recent efforts have been made to improve these error messages to explicitly name the missing fields rather than returning generic or 500-level errors [7][8]. 4. Troubleshooting Common Errors: - 400 Bad Request: Usually indicates malformed JSON, missing required fields (like attendee information or timeZone), or invalid data types (e.g., sending an integer as a string) [2][9][3]. - 500 Internal Server Error: While the API aims to return 400s for validation issues, some complex edge cases (like lack of availability or deep configuration issues) have historically resulted in 500 errors; these are actively being tracked and refactored for better clarity [2][10][8]. For the most accurate debugging, ensure your request includes the correct cal-api-version header (e.g., 2024-08-13) and check the "details" or "errors" array in the JSON response body for the specific field-level constraints [1][2][9][3].

Citations:


Avoid logging raw Cal.com response bodies.

createBooking sends attendee name, email, and optional phoneNumber. Error logging uses (await res.text()), which can contain fields such as attendee data returned in Cal.com error messages. Log only the HTTP status and a truncated non-PII field such as error.code or error.message instead of the full raw body. The success log also outputs the entire /v2/slots payload; reduce it to a short summary such as the slot count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/calcom.ts` around lines 91 - 96, Update the Cal.com response
logging in the slots request flow to avoid raw response bodies: replace the
!res.ok logging with HTTP status plus a truncated non-PII error field such as
error.code or error.message, and change the success log to report only a concise
slot-count summary instead of JSON.stringify(body.data). Preserve the existing
return behavior and locate these changes around the slots response handling and
createBooking-related error paths.

const byDate = body.data ?? {};
const slots = Object.values(byDate)
.flat()
Expand Down Expand Up @@ -127,7 +141,10 @@ export async function createBooking(
...(args.notes ? { bookingFieldsResponses: { notes: args.notes } } : {}),
}),
});
if (!res.ok) return { ok: false, reason: `http_${res.status}` };
if (!res.ok) {
console.warn(`[calcom] booking http_${res.status}:`, (await res.text()).slice(0, 300));
return { ok: false, reason: `http_${res.status}` };
}
const body = (await res.json()) as { data?: { id: number | string; uid?: string; status?: string; start?: string } };
const d = body.data;
if (!d?.id) return { ok: false, reason: "no_booking_id" };
Expand Down
31 changes: 31 additions & 0 deletions src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface SystemPromptInput {
tone?: string; // owner-chosen tone (e.g. "cálido y cercano")
extraEscalationKeywords?: string[]; // extra words that trigger a human handoff
lessons?: string[]; // flywheel: rules distilled from owner takeovers
today?: string; // fecha/hora actual en la zona del negocio
}

const TEMPLATE = `<output_language>
Expand All @@ -33,6 +34,8 @@ cliente con eficiencia y calidez, sin inventar nunca. Conoces este negocio.
Si una pregunta no tiene respuesta en lo que sabes, escalas a un humano.
</role>

{{CONTEXTO_TEMPORAL}}

<business_context>
{{BUSINESS_CONTEXT}}
</business_context>
Expand Down Expand Up @@ -122,7 +125,16 @@ ${lessons.map((l) => `- ${l}`).join("\n")}
</lecciones_aprendidas>`
: "";

const contextoTemporal = input.today
? `<contexto_temporal>
Hoy es ${input.today}. Tu conocimiento de entrenamiento tiene OTRA fecha — ignórala.
Usa SIEMPRE esta fecha real para interpretar "hoy", "mañana", "el viernes", etc.,
y para toda fecha que pases a las tools (citas, horarios).
</contexto_temporal>`
: "";

return TEMPLATE
.replaceAll("{{CONTEXTO_TEMPORAL}}", contextoTemporal)
.replaceAll("{{LANGUAGE}}", input.language)
.replaceAll("{{BOT_NAME}}", input.botName)
.replaceAll("{{BUSINESS_NAME}}", input.businessName)
Expand All @@ -141,6 +153,24 @@ export interface SystemPromptOverrides {
lessons?: string[];
}

/** Fecha/hora actual legible + ISO en la zona del negocio (ancla "hoy"/"mañana"). */
export function currentDateLine(timeZone: string): string {
const now = new Date();
const legible = new Intl.DateTimeFormat("es-MX", {
timeZone,
dateStyle: "full",
timeStyle: "short",
}).format(now);
// en-CA formatea YYYY-MM-DD, útil como fecha ISO para las tools.
const iso = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
return `${legible} (fecha ISO: ${iso}, zona horaria: ${timeZone})`;
}

export function systemPromptFromEnv(
env: Env,
toolNames: string[],
Expand All @@ -158,5 +188,6 @@ export function systemPromptFromEnv(
tone: overrides?.tone,
extraEscalationKeywords: overrides?.extraEscalationKeywords,
lessons: overrides?.lessons,
today: currentDateLine((env.CALCOM_TIMEZONE || "").trim() || "America/Mexico_City"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify DEFAULT_TZ value and current imports in system-prompt.ts
set -euo pipefail

fd -e ts . src/integrations --exec cat -n {}
echo "---"
fd system-prompt.ts src --exec cat -n {}

Repository: santmun/forja

Length of output: 15480


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Top-level files:"
fd -e ts -e toml -e json -e yaml -e yml . src . --max-depth 3
echo "--- imports containing calcom:"
rg -n 'from ["'\''](\./|../)\.?integrations/.*calcom|calcomTimeZone|DEFAULT_TZ|systemPromptFromEnv' src --glob '*.ts'

Repository: santmun/forja

Length of output: 6750


Reuse calcomTimeZone(env) in the system prompt timezone fallback.

src/system-prompt.ts:191 duplicates calcomTimeZone(env) fallback logic, while scheduleAppointmentTool also uses calcomTimeZone(env). Import calcomTimeZone from ../integrations/calcom there so changes to the trim/default behavior apply in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/system-prompt.ts` at line 191, Update the system prompt timezone
assignment near today to reuse calcomTimeZone(env) instead of duplicating the
CALCOM_TIMEZONE trim/default expression, and import calcomTimeZone from
../integrations/calcom. Keep the existing currentDateLine behavior unchanged by
passing the helper’s result to it.

});
}
86 changes: 63 additions & 23 deletions src/tools/scheduleAppointment.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,78 @@
import { tool } from "ai";
import { z } from "zod";
import type { Env } from "../env";
import {
calcomConfigured,
calcomTimeZone,
createBooking,
getAvailableSlots,
resolveEventTypeId,
todayInTz,
} from "../integrations/calcom";

const CALCOM_API = "https://api.cal.com/v1";

// El eventTypeId y la zona horaria se resuelven SIEMPRE en el servidor
// (CALCOM_EVENT_TYPE_ID / CALCOM_EVENT_TYPES / CALCOM_TIMEZONE): el modelo no
// conoce esos ids y no debe inventarlos.
export function scheduleAppointmentTool(env: Env, _getConversationId: () => string | null) {
return tool({
description:
"Agenda una cita usando Cal.com. Necesitas eventTypeId (el dueño lo configura en Cal.com), fecha/hora, nombre y email del cliente.",
"Consulta horarios libres y agenda citas reales en el calendario del negocio (Cal.com). " +
"Para ver horarios disponibles de un día: pasa solo `date` (YYYY-MM-DD). " +
"Para reservar: pasa `startTime` (ISO con offset de la zona del negocio, ej. 2026-08-03T15:00:00-06:00), " +
"`attendeeName` y `attendeeEmail` — idealmente un `startTime` que venga de los horarios consultados. " +
"Si el negocio maneja varios tipos de cita, indica `service` con el nombre del servicio.",
inputSchema: z.object({
eventTypeId: z.number().int().describe("Cal.com event type ID"),
startTime: z.string().describe("ISO datetime, e.g. 2026-06-01T17:00:00Z"),
attendeeName: z.string(),
attendeeEmail: z.string().email(),
date: z.string().optional().describe("YYYY-MM-DD para consultar horarios libres de ese día"),
startTime: z.string().optional().describe("ISO datetime con offset para reservar, ej. 2026-08-03T15:00:00-06:00"),
Comment on lines +25 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Enforce YYYY-MM-DD format before the past-date comparison.

The past-date guard at Line 43 compares requestedDay < today as strings. This only works correctly if requestedDay is zero-padded (YYYY-MM-DD). today is always correctly padded because it comes from todayInTz, but date and startTime (Lines 25-26) have no format constraint in the schema. An unpadded date from the model (for example "2027-6-1") sorts lexicographically after a padded today even when it represents an earlier calendar date, silently defeating the guard this PR intentionally adds against "fecha fantasma."

Add a regex constraint to date (and validate the date portion of startTime) so malformed values are rejected before the comparison runs.

🐛 Proposed fix
     inputSchema: z.object({
-      date: z.string().optional().describe("YYYY-MM-DD para consultar horarios libres de ese día"),
-      startTime: z.string().optional().describe("ISO datetime con offset para reservar, ej. 2026-08-03T15:00:00-06:00"),
+      date: z
+        .string()
+        .regex(/^\d{4}-\d{2}-\d{2}$/)
+        .optional()
+        .describe("YYYY-MM-DD para consultar horarios libres de ese día"),
+      startTime: z
+        .string()
+        .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
+        .optional()
+        .describe("ISO datetime con offset para reservar, ej. 2026-08-03T15:00:00-06:00"),

Also applies to: 41-49

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tools/scheduleAppointment.ts` around lines 25 - 26, Enforce strict
zero-padded YYYY-MM-DD validation in the scheduleAppointment schema’s date
field, and validate the date portion extracted from startTime before the
past-date guard compares it with today. Reject malformed or unpadded values
before reaching the requestedDay < today comparison, while preserving valid date
and ISO datetime handling.

attendeeName: z.string().optional(),
attendeeEmail: z.string().email().optional(),
service: z.string().optional().describe("nombre del servicio/tipo de cita solicitado"),
notes: z.string().optional(),
}),
execute: async ({ eventTypeId, startTime, attendeeName, attendeeEmail, notes }) => {
if (!env.CALCOM_API_KEY) return { error: "calcom_not_configured" as const };
try {
const res = await fetch(`${CALCOM_API}/bookings?apiKey=${env.CALCOM_API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eventTypeId,
start: startTime,
responses: { name: attendeeName, email: attendeeEmail, notes: notes ?? "" },
}),
execute: async ({ date, startTime, attendeeName, attendeeEmail, service, notes }) => {
if (!calcomConfigured(env)) return { error: "calcom_not_configured" as const };
const eventTypeId = resolveEventTypeId(env, service);
if (eventTypeId == null) return { error: "calcom_not_configured" as const };
const timeZone = calcomTimeZone(env);

// Guardia anti-fecha-fantasma: los LLM no saben qué día es hoy y suelen
// proponer fechas de su época de entrenamiento. YYYY-MM-DD compara bien
// como string.
const today = todayInTz(timeZone);
const requestedDay = date ?? startTime?.slice(0, 10);
if (requestedDay && requestedDay < today) {
return {
error: "date_in_past" as const,
today,
hint: `Hoy es ${today}. Recalcula la fecha pedida por el cliente a partir de hoy y reintenta.`,
};
}

// Reservar: requiere hora exacta + datos del cliente.
if (startTime && attendeeName && attendeeEmail) {
const r = await createBooking(env, {
eventTypeId,
start: startTime,
name: attendeeName,
email: attendeeEmail,
timeZone,
notes,
});
if (!res.ok) return { error: "calcom_failed" as const, status: res.status };
const body = (await res.json()) as any;
return { bookingId: body.id, status: body.status };
} catch (e: any) {
return { error: "transient" as const, message: String(e?.message ?? e) };
if (!r.ok) return { error: "calcom_failed" as const, reason: r.reason };
return { booked: true, bookingId: r.bookingId, status: r.status, start: r.start ?? startTime };
}

// Consultar horarios libres de un día.
if (date) {
const r = await getAvailableSlots(env, eventTypeId, date, timeZone);
if (!r.ok) return { error: "calcom_failed" as const, reason: r.reason };
return { date, timeZone, slots: r.slots.slice(0, 12) };
}

return {
error: "missing_params" as const,
hint: "Pasa `date` para ver horarios, o `startTime` + `attendeeName` + `attendeeEmail` para reservar.",
};
},
});
}
86 changes: 65 additions & 21 deletions test/tools/scheduleAppointment.test.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,97 @@
import { describe, it, expect, vi } from "vitest";
import { scheduleAppointmentTool } from "../../src/tools/scheduleAppointment";

// El tool ya no recibe eventTypeId del modelo: se resuelve del env
// (CALCOM_EVENT_TYPE_ID / CALCOM_EVENT_TYPES) y llama Cal.com API v2.
describe("scheduleAppointmentTool", () => {
it("creates Cal.com booking via API", async () => {
global.fetch = vi.fn(
const baseEnv = { CALCOM_API_KEY: "fake", CALCOM_EVENT_TYPE_ID: "100", BOT_TIER: "pro" } as any;

it("creates Cal.com booking via API v2 with server-resolved eventTypeId", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ id: 12345, status: "ACCEPTED" }), { status: 201 }),
) as any;
const env = { CALCOM_API_KEY: "fake", BOT_TIER: "pro" } as any;
const tool = scheduleAppointmentTool(env, () => "conv_x");
new Response(JSON.stringify({ data: { id: 12345, status: "accepted" } }), { status: 201 }),
);
global.fetch = fetchMock as any;
const tool = scheduleAppointmentTool(baseEnv, () => "conv_x");
const result = (await tool.execute!(
{
eventTypeId: 100,
startTime: "2026-06-01T17:00:00Z",
startTime: "2027-06-01T17:00:00-06:00",
attendeeName: "María",
attendeeEmail: "maria@x.com",
},
{} as any,
)) as { bookingId: number; status: string };
)) as { booked: boolean; bookingId: number };
expect(result.booked).toBe(true);
expect(result.bookingId).toBe(12345);

const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toContain("/v2/bookings");
const body = JSON.parse(String(init.body));
expect(body.eventTypeId).toBe(100);
expect(body.attendee.email).toBe("maria@x.com");
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer fake");
});

it("lists available slots when only date is given", async () => {
global.fetch = vi.fn(
async () =>
new Response(
JSON.stringify({ data: { "2027-06-01": [{ start: "2027-06-01T15:00:00-06:00" }] } }),
{ status: 200 },
),
) as any;
const tool = scheduleAppointmentTool(baseEnv, () => "conv_x");
const result = (await tool.execute!({ date: "2027-06-01" }, {} as any)) as {
slots: string[];
};
expect(result.slots).toEqual(["2027-06-01T15:00:00-06:00"]);
});

it("returns error when Cal.com fails", async () => {
global.fetch = vi.fn(async () => new Response("err", { status: 400 })) as any;
const env = { CALCOM_API_KEY: "fake", BOT_TIER: "pro" } as any;
const tool = scheduleAppointmentTool(env, () => "conv_x");
const tool = scheduleAppointmentTool(baseEnv, () => "conv_x");
const result = (await tool.execute!(
{
eventTypeId: 100,
startTime: "2026-06-01T17:00:00Z",
startTime: "2027-06-01T17:00:00-06:00",
attendeeName: "María",
attendeeEmail: "maria@x.com",
},
{} as any,
)) as { error: string };
)) as { error: string; reason: string };
expect(result.error).toBe("calcom_failed");
expect(result.reason).toBe("http_400");
});

it("rejects past dates without calling Cal.com", async () => {
global.fetch = vi.fn() as any;
const tool = scheduleAppointmentTool(baseEnv, () => "conv_x");
const result = (await tool.execute!({ date: "2023-10-04" }, {} as any)) as {
error: string;
today: string;
};
expect(result.error).toBe("date_in_past");
expect(result.today > "2023-10-04").toBe(true);
expect(global.fetch).not.toHaveBeenCalled();
});

it("returns calcom_not_configured when no API key", async () => {
global.fetch = vi.fn() as any;
const env = { BOT_TIER: "pro" } as any;
const env = { CALCOM_EVENT_TYPE_ID: "100", BOT_TIER: "pro" } as any;
const tool = scheduleAppointmentTool(env, () => "conv_x");
const result = (await tool.execute!(
{
eventTypeId: 100,
startTime: "2026-06-01T17:00:00Z",
attendeeName: "María",
attendeeEmail: "maria@x.com",
},
{ startTime: "2027-06-01T17:00:00-06:00", attendeeName: "María", attendeeEmail: "maria@x.com" },
{} as any,
)) as { error: string };
expect(result.error).toBe("calcom_not_configured");
expect(global.fetch).not.toHaveBeenCalled();
});

it("returns calcom_not_configured when no event type is configured", async () => {
global.fetch = vi.fn() as any;
const env = { CALCOM_API_KEY: "fake", BOT_TIER: "pro" } as any;
const tool = scheduleAppointmentTool(env, () => "conv_x");
const result = (await tool.execute!(
{ startTime: "2027-06-01T17:00:00-06:00", attendeeName: "María", attendeeEmail: "maria@x.com" },
{} as any,
)) as { error: string };
expect(result.error).toBe("calcom_not_configured");
Expand Down