API v1.0

CultivaCRM — API de Campañas Estable

Diseño contract-first de la REST API de gestión publicitaria. Interfaces TypeScript, errores consistentes, paginación cursor-based y cero breaking changes.

Cliente: CultivaCRM SaaS
Stack: Node.js · TypeScript · Zod · PostgreSQL
Versión base: /api/v1
Skill: diseno-apis-interfaces-estables
⚖️
Principios de Estabilidad
La base filosófica que guía cada decisión de diseño
"Con suficientes usuarios de una API, todos los comportamientos observables del sistema serán dependencia de alguien, sin importar lo que prometa el contrato."
— Hyrum's Law · La ley que CultivaCRM violó dos veces en tres meses
01 · Contract-First
Define antes de implementar
Los tipos TypeScript son la spec. El código implementa la spec, nunca al revés. Si no hay interface definida, no hay implementación.
02 · One-Version Rule
Una sola versión viva
Extender, no bifurcar. Dos versiones multiplican el coste de mantenimiento y crean problemas de dependencias en diamante.
03 · Validar en Fronteras
Confianza interna, validación externa
Solo en route handlers (input usuario) y al parsear respuestas de terceros. Nunca entre funciones internas que comparten tipos.
04 · Adición, no Modificación
Solo campos opcionales nuevos
Añadir campos opcionales: siempre seguro. Eliminar campos, cambiar tipos o renombrar: breaking change. Diseña para extender desde el día 1.
TypeScript Strict REST Semántico Zod Validation Branded Types Cursor Pagination Discriminated Unions
📝
Contratos TypeScript — Campaign API
Los tipos son la documentación. Definidos antes que el código.

Branded IDs — Imposible confundir CampaignId con AdId

TypeScript
types/ids.ts
// Branded types — el compilador rechaza mezclas de IDs
export type CampaignId = string & { readonly __brand: 'CampaignId' };
export type AdId       = string & { readonly __brand: 'AdId' };
export type ClientId  = string & { readonly __brand: 'ClientId' };

// Helpers de casting seguro
export const toCampaignId = (s: string): CampaignId => s as CampaignId;
export const toAdId       = (s: string): AdId       => s as AdId;

// Error de compilación si mezclas — previene bugs en producción
// getCampaign(adId)  ← TypeScript error ✓

Estado de Campaña — Discriminated Union

TypeScript
types/campaign.ts
// Discriminated union — cada estado lleva sus propios campos
export type CampaignStatus =
  | { type: 'draft' }
  | { type: 'scheduled';  scheduledAt: Date }
  | { type: 'active';     startedAt: Date;  budget: number }
  | { type: 'paused';    pausedAt: Date;   reason: string }
  | { type: 'completed'; endedAt: Date;    totalSpend: number }
  | { type: 'cancelled'; cancelledAt: Date; by: UserId };

// Input (lo que el cliente envía)
export interface CreateCampaignInput {
  name:        string;
  clientId:    ClientId;
  platform:    'META' | 'GOOGLE' | 'TIKTOK' | 'LINKEDIN';
  budget:      number;                   // EUR, centavos
  objective:   'AWARENESS' | 'LEADS' | 'CONVERSIONS';
  scheduledAt?: Date;                    // opcional — puede ser inmediata
  labels?:     string[];               // añadido en v1.1, sigue opcional
}

// Output (lo que el servidor devuelve — incluye campos generados)
export interface Campaign {
  id:          CampaignId;
  name:        string;
  clientId:    ClientId;
  platform:    'META' | 'GOOGLE' | 'TIKTOK' | 'LINKEDIN';
  budget:      number;
  objective:   'AWARENESS' | 'LEADS' | 'CONVERSIONS';
  status:      CampaignStatus;
  labels:      string[];
  createdAt:   Date;
  updatedAt:   Date;
  createdBy:   UserId;
}

Interfaz de la API — Contract-First

TypeScript
contracts/CampaignAPI.ts
// Este archivo define el contrato. El servidor lo implementa.
// El cliente (React / SDK / Zapier) lo consume. Nunca al revés.
export interface CampaignAPI {
  /** Crea campaña y devuelve el objeto completo con ID generado */
  createCampaign(input: CreateCampaignInput): Promise<Campaign>;

  /** Lista campañas con cursor-based pagination */
  listCampaigns(params: ListCampaignsParams): Promise<CursorPage<Campaign>>;

  /** Devuelve campaña o lanza NotFoundError */
  getCampaign(id: CampaignId): Promise<Campaign>;

  /** Actualización parcial — solo cambian los campos proporcionados */
  updateCampaign(id: CampaignId, input: UpdateCampaignInput): Promise<Campaign>;

  /** Delete idempotente — éxito aunque ya esté eliminada */
  deleteCampaign(id: CampaignId): Promise<void>;

  /** Sub-recurso: anuncios de una campaña */
  listAds(campaignId: CampaignId, params: ListAdsParams): Promise<CursorPage<Ad>>;
  createAd(campaignId: CampaignId, input: CreateAdInput): Promise<Ad>;

  /** Métricas agregadas — solo lectura */
  getMetrics(campaignId: CampaignId, params: MetricsParams): Promise<CampaignMetrics>;
}
🗺️
Mapa de Endpoints REST
Naming consistente: sustantivos plurales, sin verbos en la URL
GET /api/v1/campaigns Listar campañas (cursor paginado)
POST /api/v1/campaigns Crear nueva campaña → 201
GET /api/v1/campaigns/:campaignId Obtener campaña por ID
PATCH /api/v1/campaigns/:campaignId Actualización parcial (solo campos enviados)
DELETE /api/v1/campaigns/:campaignId Eliminar (idempotente) → 204
GET /api/v1/campaigns/:campaignId/ads Listar anuncios de campaña
POST /api/v1/campaigns/:campaignId/ads Añadir anuncio a campaña
PATCH /api/v1/campaigns/:campaignId/ads/:adId Editar anuncio específico
GET /api/v1/campaigns/:campaignId/metrics Métricas agregadas (solo lectura)
📄
Cursor Pagination
Para volumen alto y tiempo real
TypeScript
types/pagination.ts
// Request
interface ListCampaignsParams {
  cursor?:    string; // opaque
  limit?:    number; // default 20, max 100
  status?:   CampaignStatus['type'];
  platform?: string;
  clientId?: ClientId;
  sortBy?:   'createdAt' | 'updatedAt' | 'budget';
  sortOrder?:'asc' | 'desc';
}

// Response shape
interface CursorPage<T> {
  data:            T[];
  nextCursor:     string | null;
  hasMore:        boolean;
  totalCount?:    number; // caro, optional
}

Cursor opaco (base64 de {id + createdAt}). No depende del offset — no se rompe con inserciones concurrentes.

🛡️
Validación en Frontera
Solo en route handlers, con Zod
TypeScript
routes/campaigns.ts
const CreateCampaignSchema = z.object({
  name:     z.string().min(1).max(120),
  clientId: z.string().uuid(),
  platform: z.enum(['META','GOOGLE','TIKTOK','LINKEDIN']),
  budget:   z.number().int().positive(),
  objective:z.enum(['AWARENESS','LEADS','CONVERSIONS']),
  scheduledAt:z.date().optional(),
  labels:   z.array(z.string()).optional(),
});

app.post('/api/v1/campaigns', async (req, res) => {
  const result = CreateCampaignSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(422).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Datos de campaña inválidos',
        details: result.error.flatten(),
      }
    });
  }
  // A partir de aquí el código interno confía en los tipos
  const campaign = await campaignService.create(result.data);
  return res.status(201).json(campaign);
});
Errores Consistentes — Un único formato en toda la API
El problema del CultivaCRM anterior: 3 formatos distintos de error según el endpoint
TypeScript
types/errors.ts
// SIEMPRE esta forma, sin excepciones
interface APIError {
  error: {
    code:    string; // SNAKE_UPPER legible por máquina
    message: string; // Texto para el humano
    details?: unknown; // Contexto extra si aplica
  };
}
JSON
Ejemplo respuesta 422
{
  "error": {
    "code":    "VALIDATION_ERROR",
    "message": "Datos de campaña inválidos",
    "details": {
      "fieldErrors": {
        "budget": ["Expected positive integer"],
        "platform": ["Invalid enum value"]
      }
    }
  }
}
HTTP Status Error Code Cuándo usarlo
400 BAD_REQUEST El cliente envió datos malformados (JSON inválido, parámetros imposibles)
401 UNAUTHENTICATED No hay token de sesión o está expirado
403 FORBIDDEN Autenticado pero sin permiso para este recurso (campaña de otro cliente)
404 NOT_FOUND La campaña con ese ID no existe o fue eliminada
409 CONFLICT Nombre de campaña duplicado para ese cliente, o conflicto de versión
422 VALIDATION_ERROR JSON válido pero semánticamente incorrecto (budget negativo, fecha pasada)
500 INTERNAL_ERROR Error del servidor — nunca exponer stack trace ni detalles internos
🚩
Red Flags vs. Corrección
Lo que había antes vs. lo que hay ahora
Antes — Roto
/api/createCampaign
Ahora — Correcto
POST /api/v1/campaigns
Antes — Roto
{ success: true, data: {...} }
Ahora — Correcto
{ ...campaign } → 201
Antes — Roto
GET /api/campaigns sin paginación
Ahora — Correcto
?cursor=&limit=20
Antes — Roto
status: "InProgress" o "in-progress"
Ahora — Correcto
status.type: "active" (enum fijo)
Antes — Roto
Response de tercero sin validar
Ahora — Correcto
z.parse() antes de usar datos externos
Checklist de Verificación
Antes de hacer merge de cualquier endpoint nuevo
  • Tipos Input/Output definidos CreateCampaignInput + Campaign exportados en contracts/
  • Errores en formato único APIError Ningún endpoint devuelve { success: false } ni string
  • Validación solo en route handler campaignService nunca valida input — confía en los tipos
  • Listas con cursor pagination CursorPage<T> en todos los listCampaigns / listAds
  • Nuevos campos = opcionales labels?: string[] — nunca campo requerido nuevo en v1
  • Naming conventions homogéneo camelCase en body, UPPER_SNAKE en enums, plural en URLs
  • Tipos junto al código contracts/ commiteado en el mismo PR que la implementación
CULTIVA IA · Skill: diseno-apis-interfaces-estables · CultivaCRM SaaS — API v1.0 · Generado por agente