ponch-mcp-server 1.0.70 → 1.0.72

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.
package/dist/index.js CHANGED
@@ -133,6 +133,14 @@ var Session = class {
133
133
  get userName() {
134
134
  return this._authContext.userName;
135
135
  }
136
+ /** uid del usuario logueado. Necesario para audit log (211.1 HITO 6). */
137
+ get userId() {
138
+ return this._authContext.userId;
139
+ }
140
+ /** Rol del usuario (admin/encargado/empleado/super_admin). 211.1 HITO 6 */
141
+ get rol() {
142
+ return this._authContext.rol;
143
+ }
136
144
  setContext(tenantId, brandId) {
137
145
  if (this._authContext.mode !== "cowork") {
138
146
  throw new Error("set_context solo disponible en Modo Cowork");
@@ -408,27 +416,26 @@ function registerContextTools(server, session) {
408
416
  brandId: import_zod.z.string().describe('ID de la brand (ej: "ponch")')
409
417
  },
410
418
  async ({ tenantId, brandId }) => {
411
- const config = await readDoc("marketing_config", tenantId);
412
- if (!config) {
413
- return {
414
- content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Tenant "${tenantId}" no tiene marketing_config` }) }]
415
- };
416
- }
417
- const brands = config.brands ?? {};
418
- if (!brands[brandId]) {
419
+ const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
420
+ if (!brand) {
421
+ const brandsSnap = await getAdminDb().collection("tenants").doc(tenantId).collection("marketing_config").get();
422
+ if (brandsSnap.empty) {
423
+ return {
424
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Tenant "${tenantId}" no tiene marketing_config` }) }]
425
+ };
426
+ }
419
427
  return {
420
428
  content: [{
421
429
  type: "text",
422
430
  text: JSON.stringify({
423
431
  ok: false,
424
432
  error: `Brand "${brandId}" no existe en tenant "${tenantId}"`,
425
- brandsDisponibles: Object.keys(brands)
433
+ brandsDisponibles: brandsSnap.docs.map((d) => d.id)
426
434
  })
427
435
  }]
428
436
  };
429
437
  }
430
438
  session.setContext(tenantId, brandId);
431
- const brand = brands[brandId];
432
439
  return {
433
440
  content: [{
434
441
  type: "text",
@@ -522,7 +529,7 @@ function registerContextTools(server, session) {
522
529
  }
523
530
 
524
531
  // src/tools/core.ts
525
- var import_zod14 = require("zod");
532
+ var import_zod26 = require("zod");
526
533
 
527
534
  // ../packages/marketing-schemas/dist/index.js
528
535
  var import_zod2 = require("zod");
@@ -537,6 +544,18 @@ var import_zod10 = require("zod");
537
544
  var import_zod11 = require("zod");
538
545
  var import_zod12 = require("zod");
539
546
  var import_zod13 = require("zod");
547
+ var import_zod14 = require("zod");
548
+ var import_zod15 = require("zod");
549
+ var import_zod16 = require("zod");
550
+ var import_zod17 = require("zod");
551
+ var import_zod18 = require("zod");
552
+ var import_zod19 = require("zod");
553
+ var import_zod20 = require("zod");
554
+ var import_zod21 = require("zod");
555
+ var import_zod22 = require("zod");
556
+ var import_zod23 = require("zod");
557
+ var import_zod24 = require("zod");
558
+ var import_zod25 = require("zod");
540
559
  var MARKETING_CONSTRAINTS = {
541
560
  // ═══════════════════════════════════════════════════════════
542
561
  // AUDIT — Legacy Content Audit (killer feature)
@@ -662,23 +681,24 @@ var MARKETING_CONSTRAINTS = {
662
681
  var SOURCE_PLATFORMS = ["shopify", "wordpress", "webonly"];
663
682
  var SourceSchema = import_zod2.z.enum(SOURCE_PLATFORMS);
664
683
  var FeaturedImageSchema = import_zod2.z.object({
665
- url: import_zod2.z.string().min(10, "URL absoluta de la imagen (incluye protocolo https://)"),
666
- altText: import_zod2.z.string().min(5, "Alt text describiendo lo que se ve en la imagen, minimo 5 chars. Critico para accesibilidad + AEO").nullable().optional(),
684
+ url: import_zod2.z.string().min(1, "URL de la imagen (no vacia)"),
685
+ altText: import_zod2.z.string().nullable().optional(),
667
686
  width: import_zod2.z.number().int().positive().nullable().optional(),
668
687
  height: import_zod2.z.number().int().positive().nullable().optional()
669
688
  }).strict();
670
689
  var SeoFieldsSchema = import_zod2.z.object({
671
- metaTitle: import_zod2.z.string().max(70, "Meta title max 70 chars \u2014 Google trunca despues de 60").nullable().optional(),
672
- metaDescription: import_zod2.z.string().max(180, "Meta description max 180 chars \u2014 Google trunca despues de 158").nullable().optional(),
690
+ metaTitle: import_zod2.z.string().nullable().optional(),
691
+ metaDescription: import_zod2.z.string().nullable().optional(),
692
+ ogDescription: import_zod2.z.string().nullable().optional(),
673
693
  focusKeyword: import_zod2.z.string().nullable().optional(),
674
694
  canonicalUrl: import_zod2.z.string().nullable().optional()
675
695
  }).strict();
676
- var EmbeddingsSchema = import_zod2.z.object({
677
- /** Vertex multimodalembedding@001 — 1408 dims texto */
678
- text: import_zod2.z.array(import_zod2.z.number()).length(1408, "embedding texto debe ser array de 1408 floats (Vertex multimodalembedding@001)").nullable().optional(),
679
- /** Vertex multimodalembedding@001 — 1408 dims imagen */
680
- image: import_zod2.z.array(import_zod2.z.number()).length(1408, "embedding imagen debe ser array de 1408 floats (Vertex multimodalembedding@001)").nullable().optional()
696
+ var ExtractedHeadingsSchema = import_zod2.z.object({
697
+ h1: import_zod2.z.string().nullable(),
698
+ h2s: import_zod2.z.array(import_zod2.z.string()).default([])
681
699
  }).strict();
700
+ var EMBEDDING_TEXT = import_zod2.z.array(import_zod2.z.number()).length(1408, "embedding texto debe ser array de 1408 floats (Vertex multimodalembedding@001)").nullable().optional();
701
+ var EMBEDDING_IMAGE = import_zod2.z.array(import_zod2.z.number()).length(1408, "embedding imagen debe ser array de 1408 floats (Vertex multimodalembedding@001)").nullable().optional();
682
702
  var AdapterMetaSchema = import_zod2.z.object({
683
703
  /** WP: estructura de permalinks detectada. Ej: "/%postname%/" */
684
704
  permalinkStructure: import_zod2.z.string().nullable().optional(),
@@ -692,12 +712,13 @@ var AdapterMetaSchema = import_zod2.z.object({
692
712
  shopifyBlogId: import_zod2.z.string().nullable().optional()
693
713
  }).strict().nullable().optional();
694
714
  var ArticleSchema = import_zod2.z.object({
715
+ schemaVersion: import_zod2.z.literal(1).default(1),
695
716
  source: SourceSchema,
696
717
  platformId: import_zod2.z.string().min(1, "ID de la plataforma origen (Shopify GID o WP post ID como string)"),
697
718
  handle: import_zod2.z.string().min(1, "Slug/handle del articulo"),
698
- title: import_zod2.z.string().min(5, "Titulo del articulo, minimo 5 chars"),
699
- url: import_zod2.z.string().min(10, "URL publica absoluta del articulo"),
700
- content: import_zod2.z.string().min(100, "Body HTML del articulo, minimo 100 chars para contenido real"),
719
+ title: import_zod2.z.string().min(1, "Titulo del articulo (no vacio)"),
720
+ url: import_zod2.z.string().min(1, "URL del articulo (no vacia)"),
721
+ content: import_zod2.z.string(),
701
722
  excerpt: import_zod2.z.string().nullable().optional(),
702
723
  featuredImage: FeaturedImageSchema.nullable().optional(),
703
724
  seo: SeoFieldsSchema,
@@ -709,33 +730,63 @@ var ArticleSchema = import_zod2.z.object({
709
730
  name: import_zod2.z.string().min(1),
710
731
  url: import_zod2.z.string().nullable().optional()
711
732
  }).strict().nullable().optional(),
712
- embeddings: EmbeddingsSchema.nullable().optional(),
733
+ /**
734
+ * Blog asociado del articulo (Shopify blog / WP custom post type).
735
+ * Top-level porque es metadata estructural — UI agrupa articles por blog,
736
+ * brandBriefBuilder enriquece prompts con blog context, etc.
737
+ * Sesion 211.1 hito 5b.4 fix.
738
+ */
739
+ blogId: import_zod2.z.string().nullable().optional(),
740
+ blogHandle: import_zod2.z.string().nullable().optional(),
741
+ blogTitle: import_zod2.z.string().nullable().optional(),
742
+ embeddingText: EMBEDDING_TEXT,
743
+ embeddingImage: EMBEDDING_IMAGE,
713
744
  _adapterMeta: AdapterMetaSchema
714
745
  }).strict();
715
746
  var PageSchema = import_zod2.z.object({
747
+ schemaVersion: import_zod2.z.literal(1).default(1),
716
748
  source: SourceSchema,
717
749
  platformId: import_zod2.z.string().min(1),
718
750
  handle: import_zod2.z.string().min(1),
719
- title: import_zod2.z.string().min(3),
720
- url: import_zod2.z.string().min(10),
721
- content: import_zod2.z.string().min(50, "Content HTML de la pagina, minimo 50 chars"),
751
+ title: import_zod2.z.string().min(1),
752
+ url: import_zod2.z.string().min(1),
753
+ content: import_zod2.z.string(),
722
754
  seo: SeoFieldsSchema,
755
+ /** H1 + H2s pre-extraidos del HTML (sesion 211.1 — usado por brandBriefBuilder sin re-parsear). */
756
+ extractedHeadings: ExtractedHeadingsSchema.nullable().optional(),
757
+ /** Primer parrafo plano extraido del content HTML (sesion 211.1). */
758
+ firstParagraph: import_zod2.z.string().nullable().optional(),
723
759
  featuredImage: FeaturedImageSchema.nullable().optional(),
724
760
  publishedAt: import_zod2.z.string().nullable().optional(),
725
761
  updatedAt: import_zod2.z.string().nullable().optional(),
726
- embeddings: EmbeddingsSchema.nullable().optional(),
762
+ embeddingText: EMBEDDING_TEXT,
763
+ embeddingImage: EMBEDDING_IMAGE,
727
764
  _adapterMeta: AdapterMetaSchema
728
765
  }).strict();
729
766
  var PriceSchema = import_zod2.z.object({
730
767
  amount: import_zod2.z.number().nonnegative(),
731
768
  currencyCode: import_zod2.z.string().length(3, "currencyCode debe ser ISO 4217 (ej: USD, MXN, AUD)")
732
769
  }).strict();
770
+ var VariantSchema = import_zod2.z.object({
771
+ platformId: import_zod2.z.string().min(1, "ID de la variante en la plataforma origen (Shopify GID variant, WP variation ID)"),
772
+ sku: import_zod2.z.string().nullable().optional(),
773
+ title: import_zod2.z.string().nullable().optional(),
774
+ price: PriceSchema.nullable().optional(),
775
+ compareAtPrice: PriceSchema.nullable().optional(),
776
+ available: import_zod2.z.boolean().nullable().optional(),
777
+ inventoryQuantity: import_zod2.z.number().int().nullable().optional(),
778
+ /** Mapa de opciones: { Color: 'Rojo', Talla: 'M' } — formato platform-agnostic */
779
+ options: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string()).optional(),
780
+ image: FeaturedImageSchema.nullable().optional(),
781
+ position: import_zod2.z.number().int().nullable().optional()
782
+ }).strict();
733
783
  var ProductSchema = import_zod2.z.object({
784
+ schemaVersion: import_zod2.z.literal(1).default(1),
734
785
  source: SourceSchema,
735
786
  platformId: import_zod2.z.string().min(1),
736
787
  handle: import_zod2.z.string().min(1),
737
- title: import_zod2.z.string().min(3),
738
- url: import_zod2.z.string().min(10),
788
+ title: import_zod2.z.string().min(1),
789
+ url: import_zod2.z.string().min(1),
739
790
  description: import_zod2.z.string(),
740
791
  descriptionHtml: import_zod2.z.string().nullable().optional(),
741
792
  vendor: import_zod2.z.string().nullable().optional(),
@@ -746,27 +797,44 @@ var ProductSchema = import_zod2.z.object({
746
797
  price: PriceSchema.nullable().optional(),
747
798
  compareAtPrice: PriceSchema.nullable().optional(),
748
799
  available: import_zod2.z.boolean().nullable().optional(),
800
+ /** Variantes del producto (sesion 211.1 — Shopify variants, WP variations). */
801
+ variants: import_zod2.z.array(VariantSchema).default([]),
749
802
  seo: SeoFieldsSchema,
750
803
  collections: import_zod2.z.array(import_zod2.z.string()).optional(),
751
804
  publishedAt: import_zod2.z.string().nullable().optional(),
752
805
  updatedAt: import_zod2.z.string().nullable().optional(),
753
- embeddings: EmbeddingsSchema.nullable().optional(),
806
+ embeddingText: EMBEDDING_TEXT,
807
+ embeddingImage: EMBEDDING_IMAGE,
754
808
  _adapterMeta: AdapterMetaSchema
755
809
  }).strict();
810
+ var COLLECTION_TYPES = ["shopify_custom", "shopify_smart", "wp_category", "canonical"];
811
+ var CollectionTypeSchema = import_zod2.z.enum(COLLECTION_TYPES);
756
812
  var CollectionSchema = import_zod2.z.object({
813
+ schemaVersion: import_zod2.z.literal(1).default(1),
757
814
  source: SourceSchema,
758
815
  platformId: import_zod2.z.string().min(1),
759
816
  handle: import_zod2.z.string().min(1),
760
- title: import_zod2.z.string().min(2),
761
- url: import_zod2.z.string().min(10),
817
+ title: import_zod2.z.string().min(1),
818
+ url: import_zod2.z.string().min(1),
762
819
  description: import_zod2.z.string().nullable().optional(),
763
820
  descriptionHtml: import_zod2.z.string().nullable().optional(),
764
821
  featuredImage: FeaturedImageSchema.nullable().optional(),
765
822
  productIds: import_zod2.z.array(import_zod2.z.string()).optional(),
823
+ /**
824
+ * Cantidad de productos en la coleccion. Sesion 211.1 5b.3 fix.
825
+ * Shopify lo provee via GraphQL (productsCount). WordPress lo calcularia del
826
+ * category. Webonly: null. UI usa para "X productos" badge.
827
+ */
828
+ productsCount: import_zod2.z.number().int().nonnegative().nullable().optional(),
829
+ /**
830
+ * Tipo de coleccion en la plataforma origen. Sesion 211.1 5b.3 fix.
831
+ */
832
+ collectionType: CollectionTypeSchema.nullable().optional(),
766
833
  seo: SeoFieldsSchema,
767
834
  publishedAt: import_zod2.z.string().nullable().optional(),
768
835
  updatedAt: import_zod2.z.string().nullable().optional(),
769
- embeddings: EmbeddingsSchema.nullable().optional(),
836
+ embeddingText: EMBEDDING_TEXT,
837
+ embeddingImage: EMBEDDING_IMAGE,
770
838
  _adapterMeta: AdapterMetaSchema
771
839
  }).strict();
772
840
  var TONO_NARRATIVO_PROMPTS = {
@@ -863,6 +931,7 @@ var VisualRulesSchema = import_zod3.z.object({
863
931
  customNegatives: import_zod3.z.array(import_zod3.z.string()).default([])
864
932
  }).passthrough();
865
933
  var BrandBriefSchema = import_zod3.z.object({
934
+ schemaVersion: import_zod3.z.literal(1).default(1),
866
935
  descripcionCorta: import_zod3.z.string().min(1, "descripcionCorta es obligatorio"),
867
936
  segmento: SegmentoEnum,
868
937
  industria: IndustriaSchema.optional(),
@@ -1049,6 +1118,7 @@ var SemrushUsageSchema = import_zod4.z.object({
1049
1118
  historial: import_zod4.z.array(SemrushUsageHistorialItemSchema).optional()
1050
1119
  }).passthrough();
1051
1120
  var ConfigSchema = import_zod4.z.object({
1121
+ schemaVersion: import_zod4.z.literal(1).default(1),
1052
1122
  id: import_zod4.z.string().min(1, "id es obligatorio"),
1053
1123
  tenantId: import_zod4.z.string().min(1, "tenantId es obligatorio"),
1054
1124
  brands: import_zod4.z.record(import_zod4.z.string(), BrandConfigSchema).optional(),
@@ -1111,6 +1181,7 @@ var SemanaCalendarioSchema = import_zod5.z.object({
1111
1181
  items: import_zod5.z.array(CalendarioItemSchema).optional()
1112
1182
  }).passthrough();
1113
1183
  var CalendarioSchema = import_zod5.z.object({
1184
+ schemaVersion: import_zod5.z.literal(1).default(1),
1114
1185
  id: import_zod5.z.string().min(1, "id es obligatorio"),
1115
1186
  tenantId: import_zod5.z.string().min(1, "tenantId es obligatorio"),
1116
1187
  brandId: import_zod5.z.string().min(1, "brandId es obligatorio"),
@@ -1135,6 +1206,7 @@ var CollectionSuggestionInputSchema = import_zod6.z.object({
1135
1206
  }).passthrough();
1136
1207
  var CollectionSuggestionsInputArraySchema = import_zod6.z.array(CollectionSuggestionInputSchema).min(1, "Debe enviar al menos 1 sugerencia");
1137
1208
  var CollectionSuggestionStoredSchema = CollectionSuggestionInputSchema.extend({
1209
+ schemaVersion: import_zod6.z.literal(1).default(1),
1138
1210
  estado: import_zod6.z.enum(["pendiente", "rechazado", "aplicado_parcial", "aplicado_total"]),
1139
1211
  generadoAt: import_zod6.z.union([
1140
1212
  import_zod6.z.string(),
@@ -1339,6 +1411,7 @@ var MediaVarianteSchema = import_zod7.z.object({
1339
1411
  mediaExternalId: import_zod7.z.string().nullable().optional()
1340
1412
  }).passthrough();
1341
1413
  var ContenidoSchema = import_zod7.z.object({
1414
+ schemaVersion: import_zod7.z.literal(1).default(1),
1342
1415
  id: import_zod7.z.string().min(1, "id es obligatorio"),
1343
1416
  tenantId: import_zod7.z.string().min(1, "tenantId es obligatorio"),
1344
1417
  brandId: import_zod7.z.string().min(1, "brandId es obligatorio"),
@@ -1366,6 +1439,11 @@ var ContenidoSchema = import_zod7.z.object({
1366
1439
  externalId: import_zod7.z.string().nullable().optional()
1367
1440
  }).passthrough();
1368
1441
  function buildContenido(input) {
1442
+ if (!input.languageCode) {
1443
+ throw new Error(
1444
+ `buildContenido requiere languageCode expl\xEDcito en input. Resolver antes de invocar el builder.`
1445
+ );
1446
+ }
1369
1447
  return ContenidoSchema.parse({
1370
1448
  id: input.id,
1371
1449
  tenantId: input.tenantId,
@@ -1373,7 +1451,7 @@ function buildContenido(input) {
1373
1451
  plataforma: input.plataforma ?? null,
1374
1452
  tipo: input.tipo,
1375
1453
  keyword: input.keyword ?? null,
1376
- languageCode: input.languageCode ?? "es",
1454
+ languageCode: input.languageCode,
1377
1455
  estado: input.estado ?? "borrador",
1378
1456
  fotoId: input.fotoId ?? null,
1379
1457
  mediaUrl: input.mediaUrl ?? null,
@@ -1435,6 +1513,7 @@ var FotoLinkManualSchema = import_zod8.z.object({
1435
1513
  title: import_zod8.z.string()
1436
1514
  }).nullable();
1437
1515
  var FotoSchema = import_zod8.z.object({
1516
+ schemaVersion: import_zod8.z.literal(1).default(1),
1438
1517
  id: import_zod8.z.string().min(1, "id es obligatorio"),
1439
1518
  tenantId: import_zod8.z.string().min(1, "tenantId es obligatorio"),
1440
1519
  brandId: import_zod8.z.string().min(1, "brandId es obligatorio"),
@@ -1510,6 +1589,7 @@ var FotoBriefingNecesidadSchema = import_zod9.z.object({
1510
1589
  fechaCubierta: import_zod9.z.unknown().nullable().optional()
1511
1590
  });
1512
1591
  var FotoBriefingSchema = import_zod9.z.object({
1592
+ schemaVersion: import_zod9.z.literal(1).default(1),
1513
1593
  id: import_zod9.z.string().min(1),
1514
1594
  tenantId: import_zod9.z.string().min(1),
1515
1595
  brandId: import_zod9.z.string().min(1),
@@ -1619,6 +1699,7 @@ var PrioridadMesSchema = import_zod10.z.object({
1619
1699
  foco: import_zod10.z.string().optional()
1620
1700
  }).passthrough();
1621
1701
  var ColeccionesPriorizadasSchema = import_zod10.z.object({
1702
+ schemaVersion: import_zod10.z.literal(1).default(1),
1622
1703
  grupos: import_zod10.z.array(GrupoColeccionesSchema),
1623
1704
  prioridadesMes: import_zod10.z.record(import_zod10.z.string(), PrioridadMesSchema).optional()
1624
1705
  }).passthrough();
@@ -1650,6 +1731,7 @@ var BlogEntrySchema = import_zod10.z.object({
1650
1731
  totalArticulos: import_zod10.z.number().int().nonnegative()
1651
1732
  }).passthrough();
1652
1733
  var BlogStrategySchema = import_zod10.z.object({
1734
+ schemaVersion: import_zod10.z.literal(1).default(1),
1653
1735
  blogs: import_zod10.z.array(BlogEntrySchema).min(1, "Al menos 1 blog configurado"),
1654
1736
  defaultBlogId: import_zod10.z.string().min(1),
1655
1737
  defaultBlogHandle: import_zod10.z.string().min(1),
@@ -1835,6 +1917,7 @@ var EmbeddedDocBase = {
1835
1917
  ultimoUpdate: import_zod11.z.unknown().nullable().optional()
1836
1918
  };
1837
1919
  var ShopifyProductDocSchema = import_zod11.z.object({
1920
+ schemaVersion: import_zod11.z.literal(1).default(1),
1838
1921
  ...EmbeddedDocBase,
1839
1922
  title: import_zod11.z.string(),
1840
1923
  handle: import_zod11.z.string(),
@@ -1850,6 +1933,7 @@ var ShopifyProductDocSchema = import_zod11.z.object({
1850
1933
  collectionHandles: import_zod11.z.array(import_zod11.z.string()).default([])
1851
1934
  }).passthrough();
1852
1935
  var ShopifyCollectionDocSchema = import_zod11.z.object({
1936
+ schemaVersion: import_zod11.z.literal(1).default(1),
1853
1937
  ...EmbeddedDocBase,
1854
1938
  title: import_zod11.z.string(),
1855
1939
  handle: import_zod11.z.string(),
@@ -1866,6 +1950,7 @@ var ShopifyInlineImageSchema = import_zod11.z.object({
1866
1950
  alt: import_zod11.z.string().nullable()
1867
1951
  });
1868
1952
  var ShopifyArticleDocSchema = import_zod11.z.object({
1953
+ schemaVersion: import_zod11.z.literal(1).default(1),
1869
1954
  ...EmbeddedDocBase,
1870
1955
  title: import_zod11.z.string(),
1871
1956
  handle: import_zod11.z.string(),
@@ -1882,6 +1967,7 @@ var ShopifyArticleDocSchema = import_zod11.z.object({
1882
1967
  published_at: import_zod11.z.string().nullable()
1883
1968
  }).passthrough();
1884
1969
  var ShopifyPageDocSchema = import_zod11.z.object({
1970
+ schemaVersion: import_zod11.z.literal(1).default(1),
1885
1971
  ...EmbeddedDocBase,
1886
1972
  title: import_zod11.z.string(),
1887
1973
  handle: import_zod11.z.string(),
@@ -1905,6 +1991,7 @@ var SitePageContentSchema = import_zod12.z.object({
1905
1991
  fetchedAt: import_zod12.z.string()
1906
1992
  }).passthrough();
1907
1993
  var SiteContentSchema = import_zod12.z.object({
1994
+ schemaVersion: import_zod12.z.literal(1).default(1),
1908
1995
  id: import_zod12.z.string().min(1),
1909
1996
  tenantId: import_zod12.z.string().min(1),
1910
1997
  brandId: import_zod12.z.string().min(1),
@@ -1984,6 +2071,7 @@ var DETECTED_CONFLICT_TYPES = [
1984
2071
  ];
1985
2072
  var CONFLICT_SEVERITIES = ["info", "warning", "error"];
1986
2073
  var SuggestedActionSchema = import_zod13.z.object({
2074
+ schemaVersion: import_zod13.z.literal(1).default(1),
1987
2075
  id: import_zod13.z.enum(ALLOWED_ACTION_IDS),
1988
2076
  labelKey: import_zod13.z.string().regex(/^marketing\.actions\./, {
1989
2077
  message: 'labelKey debe empezar con "marketing.actions."'
@@ -2000,6 +2088,456 @@ var DetectedConflictSchema = import_zod13.z.object({
2000
2088
  message: import_zod13.z.string().max(500),
2001
2089
  relatedItems: import_zod13.z.array(import_zod13.z.unknown()).optional()
2002
2090
  });
2091
+ var TenantStatusEnum = import_zod14.z.enum([
2092
+ "active",
2093
+ "trial",
2094
+ "suspended",
2095
+ "cancelled",
2096
+ "pending_payment"
2097
+ ]);
2098
+ var TrialInfoSchema = import_zod14.z.object({
2099
+ inicioTrial: import_zod14.z.unknown().nullable(),
2100
+ finTrial: import_zod14.z.unknown().nullable()
2101
+ }).strict().nullable().optional();
2102
+ var SuspensionInfoSchema = import_zod14.z.object({
2103
+ fecha: import_zod14.z.unknown(),
2104
+ motivo: import_zod14.z.string(),
2105
+ gracePeriodDias: import_zod14.z.number(),
2106
+ finGracePeriod: import_zod14.z.unknown()
2107
+ }).strict().nullable().optional();
2108
+ var CancellationInfoSchema = import_zod14.z.object({
2109
+ fecha: import_zod14.z.unknown(),
2110
+ motivo: import_zod14.z.string(),
2111
+ iniciadaPor: import_zod14.z.enum(["user", "admin", "system"]),
2112
+ gracePeriodDias: import_zod14.z.number(),
2113
+ finGracePeriod: import_zod14.z.unknown()
2114
+ }).strict().nullable().optional();
2115
+ var TenantStatusFieldsSchema = import_zod14.z.object({
2116
+ schemaVersion: import_zod14.z.literal(1).default(1),
2117
+ status: TenantStatusEnum.default("active"),
2118
+ trial: TrialInfoSchema,
2119
+ suspension: SuspensionInfoSchema,
2120
+ cancellation: CancellationInfoSchema
2121
+ }).passthrough();
2122
+ var AuditActorSchema = import_zod15.z.object({
2123
+ type: import_zod15.z.enum(["user", "martin", "system", "cf_scheduled", "webhook"]),
2124
+ uid: import_zod15.z.string().nullable(),
2125
+ nombre: import_zod15.z.string(),
2126
+ metadata: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.any()).optional()
2127
+ }).strict();
2128
+ var AuditChangesSchema = import_zod15.z.object({
2129
+ before: import_zod15.z.any().nullable(),
2130
+ after: import_zod15.z.any().nullable()
2131
+ }).strict();
2132
+ var AuditLogEntrySchema = import_zod15.z.object({
2133
+ schemaVersion: import_zod15.z.literal(1).default(1),
2134
+ id: import_zod15.z.string(),
2135
+ tenantId: import_zod15.z.string(),
2136
+ brandId: import_zod15.z.string().nullable(),
2137
+ // Quién
2138
+ actor: AuditActorSchema,
2139
+ // Qué
2140
+ action: import_zod15.z.string().min(1, 'action can\xF3nico es obligatorio (ej. "contenido.descartar")'),
2141
+ targetType: import_zod15.z.string(),
2142
+ targetPath: import_zod15.z.string(),
2143
+ targetId: import_zod15.z.string(),
2144
+ // Cómo
2145
+ changes: AuditChangesSchema,
2146
+ // Contexto
2147
+ motivo: import_zod15.z.string().nullable(),
2148
+ conversacionId: import_zod15.z.string().nullable(),
2149
+ // Resultado
2150
+ status: import_zod15.z.enum(["success", "error", "partial"]),
2151
+ errorMessage: import_zod15.z.string().nullable(),
2152
+ durationMs: import_zod15.z.number().nonnegative(),
2153
+ // Cuándo (Firestore serverTimestamp)
2154
+ timestamp: import_zod15.z.unknown(),
2155
+ // Origen técnico (debugging, NO mostrar al usuario)
2156
+ origenTecnico: import_zod15.z.object({
2157
+ cfName: import_zod15.z.string().optional(),
2158
+ helperName: import_zod15.z.string().optional(),
2159
+ requestId: import_zod15.z.string().optional()
2160
+ }).optional()
2161
+ }).strict();
2162
+ function buildAuditLogEntry(input) {
2163
+ return AuditLogEntrySchema.parse(input);
2164
+ }
2165
+ var PrecioRegionSchema = import_zod16.z.object({
2166
+ mensual: import_zod16.z.number().nonnegative(),
2167
+ anual: import_zod16.z.number().nonnegative(),
2168
+ moneda: import_zod16.z.string().length(3, "moneda debe ser ISO 4217 (USD, MXN, AUD, etc.)")
2169
+ }).strict();
2170
+ var MembresiaAddonSchema = import_zod16.z.object({
2171
+ schemaVersion: import_zod16.z.literal(1).default(1),
2172
+ id: import_zod16.z.string().min(1),
2173
+ nombre: import_zod16.z.string().min(1),
2174
+ descripcion: import_zod16.z.string(),
2175
+ activo: import_zod16.z.boolean(),
2176
+ /** Diccionario de límites a incrementar. Ej: { brandsIncluidas: 1 } */
2177
+ incrementa: import_zod16.z.record(import_zod16.z.string(), import_zod16.z.number()),
2178
+ /** Precio default (si la región del tenant no tiene override) */
2179
+ precio: PrecioRegionSchema,
2180
+ /** Precios por región: { MX, US, AR, ES, AU, ... } */
2181
+ preciosPorRegion: import_zod16.z.record(import_zod16.z.string(), PrecioRegionSchema).optional(),
2182
+ /** Planes a los que se puede aplicar. Ej: ['pro', 'enterprise'] */
2183
+ aplicableA: import_zod16.z.array(import_zod16.z.string()),
2184
+ /** Máximo de unidades por tenant. null = sin tope */
2185
+ maxPorTenant: import_zod16.z.number().int().nullable(),
2186
+ fechaCreacion: import_zod16.z.string()
2187
+ }).strict();
2188
+ var AddonActivoSchema = import_zod16.z.object({
2189
+ addonId: import_zod16.z.string(),
2190
+ cantidad: import_zod16.z.number().int().positive(),
2191
+ fechaCompra: import_zod16.z.unknown(),
2192
+ /** null = suscripción mensual continua */
2193
+ finVigencia: import_zod16.z.unknown().nullable(),
2194
+ estado: import_zod16.z.enum(["active", "cancelled"])
2195
+ }).strict();
2196
+ var TipoMemoriaEnum = import_zod17.z.enum([
2197
+ "filtro",
2198
+ "preferencia",
2199
+ "patron",
2200
+ "aprendido",
2201
+ "delegacion"
2202
+ ]);
2203
+ var StatusMemoriaEnum = import_zod17.z.enum([
2204
+ "explicito",
2205
+ "explicito_confirmado",
2206
+ "inferido",
2207
+ "archivado"
2208
+ ]);
2209
+ var CategoriaMemoriaEnum = import_zod17.z.enum([
2210
+ "compras",
2211
+ "produccion",
2212
+ "dispatch",
2213
+ "ventas",
2214
+ "marketing",
2215
+ "operacion",
2216
+ "personal",
2217
+ "delegacion"
2218
+ ]);
2219
+ var OrigenMemoriaSchema = import_zod17.z.object({
2220
+ tipo: import_zod17.z.enum(["conversacion", "feedback_card", "inferido_automatico"]),
2221
+ conversacionId: import_zod17.z.string().nullable(),
2222
+ /** Si vino de feedback long-press en un card */
2223
+ cardId: import_zod17.z.string().nullable(),
2224
+ /** Razones que la generaron (para debugging + transparencia) */
2225
+ rationale: import_zod17.z.array(import_zod17.z.string()).optional()
2226
+ }).strict();
2227
+ var MartinMemoriaSchema = import_zod17.z.object({
2228
+ schemaVersion: import_zod17.z.literal(1).default(1),
2229
+ id: import_zod17.z.string().min(1, "id es obligatorio"),
2230
+ tenantId: import_zod17.z.string().min(1, "tenantId es obligatorio"),
2231
+ /** Usuario dueño de la memoria */
2232
+ uid: import_zod17.z.string().min(1, "uid es obligatorio"),
2233
+ tipo: TipoMemoriaEnum,
2234
+ categoria: CategoriaMemoriaEnum,
2235
+ contenido: import_zod17.z.string().min(3, "contenido debe tener al menos 3 chars").max(500, "contenido max 500 chars"),
2236
+ origen: OrigenMemoriaSchema,
2237
+ status: StatusMemoriaEnum.default("explicito"),
2238
+ /** Veces que Martin la usó */
2239
+ aplicacionesCount: import_zod17.z.number().int().nonnegative().default(0),
2240
+ ultimaAplicacionAt: import_zod17.z.string().datetime().nullable().default(null),
2241
+ createdAt: import_zod17.z.string().datetime(),
2242
+ updatedAt: import_zod17.z.string().datetime()
2243
+ }).strict();
2244
+ var TipoRutinaEnum = import_zod18.z.enum([
2245
+ "reporte",
2246
+ "recordatorio",
2247
+ "accion_delegada",
2248
+ "publicacion",
2249
+ "compra_sugerida"
2250
+ ]);
2251
+ var FrecuenciaRutinaEnum = import_zod18.z.enum([
2252
+ "diaria",
2253
+ "semanal",
2254
+ "quincenal",
2255
+ "mensual",
2256
+ "trimestral",
2257
+ "puntual"
2258
+ ]);
2259
+ var StatusRutinaEnum = import_zod18.z.enum([
2260
+ "activa",
2261
+ "pausada",
2262
+ "archivada",
2263
+ "completada"
2264
+ ]);
2265
+ var ConfigRutinaSchema = import_zod18.z.object({
2266
+ /** Día semana 0=dom, 6=sab. Para semanal/quincenal */
2267
+ diaSemana: import_zod18.z.number().int().min(0).max(6).nullable(),
2268
+ /** Día mes 1-31. Para mensual/trimestral */
2269
+ diaMes: import_zod18.z.number().int().min(1).max(31).nullable(),
2270
+ /** Hora "HH:MM" en la timezone del tenant (heredada de tenants/{tid}.zonaHoraria) */
2271
+ hora: import_zod18.z.string().regex(/^\d{2}:\d{2}$/, "hora debe ser formato HH:MM (24h)"),
2272
+ /** Si frecuencia=puntual, fecha exacta ISO con offset */
2273
+ fechaPuntual: import_zod18.z.string().datetime({ offset: true }).nullable()
2274
+ }).strict();
2275
+ var AccionRutinaSchema = import_zod18.z.object({
2276
+ /** Nombre del tool MCP a invocar */
2277
+ tool: import_zod18.z.string().min(1),
2278
+ /** Parámetros para el tool */
2279
+ params: import_zod18.z.record(import_zod18.z.string(), import_zod18.z.unknown())
2280
+ }).strict();
2281
+ var OrigenRutinaSchema = import_zod18.z.object({
2282
+ tipo: import_zod18.z.enum(["conversacion", "feedback_card", "configurada_explicito"]),
2283
+ conversacionId: import_zod18.z.string().nullable(),
2284
+ cardId: import_zod18.z.string().nullable()
2285
+ }).strict();
2286
+ var MartinRutinaSchema = import_zod18.z.object({
2287
+ schemaVersion: import_zod18.z.literal(1).default(1),
2288
+ id: import_zod18.z.string().min(1),
2289
+ tenantId: import_zod18.z.string().min(1),
2290
+ /** Quién la creó */
2291
+ uidCreador: import_zod18.z.string().min(1),
2292
+ /** A quién va dirigida (puede ser otro empleado) */
2293
+ uidDestinatario: import_zod18.z.string().min(1),
2294
+ tipo: TipoRutinaEnum,
2295
+ frecuencia: FrecuenciaRutinaEnum,
2296
+ config: ConfigRutinaSchema,
2297
+ accion: AccionRutinaSchema,
2298
+ origen: OrigenRutinaSchema,
2299
+ status: StatusRutinaEnum.default("activa"),
2300
+ ejecucionesCount: import_zod18.z.number().int().nonnegative().default(0),
2301
+ ultimaEjecucionAt: import_zod18.z.string().datetime().nullable().default(null),
2302
+ /** Calculado para optimizar query del cron */
2303
+ proximaEjecucionAt: import_zod18.z.string().datetime(),
2304
+ createdAt: import_zod18.z.string().datetime(),
2305
+ updatedAt: import_zod18.z.string().datetime()
2306
+ }).strict();
2307
+ var MenuItemSchema = import_zod19.z.lazy(
2308
+ () => import_zod19.z.object({
2309
+ title: import_zod19.z.string().min(1, "Texto visible del item del menu"),
2310
+ url: import_zod19.z.string().min(1, "URL del item (puede ser absoluta o relativa)"),
2311
+ /** Tipo de destino: collection / page / product / catalog / blog / article / etc. */
2312
+ type: import_zod19.z.string().nullable().optional(),
2313
+ /** ID del recurso al que apunta el item (collection ID, page ID, etc.) */
2314
+ resourceId: import_zod19.z.string().nullable().optional(),
2315
+ /** Sub-items recursivos (sub-menus). Default array vacio. */
2316
+ items: import_zod19.z.array(MenuItemSchema).default([])
2317
+ }).strict()
2318
+ );
2319
+ var MENU_ASSIGNMENTS = ["header", "footer", "unassigned"];
2320
+ var MenuAssignmentSchema = import_zod19.z.enum(MENU_ASSIGNMENTS);
2321
+ var MenuSchema = import_zod19.z.object({
2322
+ schemaVersion: import_zod19.z.literal(1).default(1),
2323
+ source: SourceSchema,
2324
+ platformId: import_zod19.z.string().min(1, "ID del menu en la plataforma origen"),
2325
+ handle: import_zod19.z.string().min(1, "Handle/slug del menu"),
2326
+ title: import_zod19.z.string().min(1, "Titulo del menu"),
2327
+ /** Donde aparece el menu en el sitio */
2328
+ assignedTo: MenuAssignmentSchema.default("unassigned"),
2329
+ items: import_zod19.z.array(MenuItemSchema).default([]),
2330
+ updatedAt: import_zod19.z.string().nullable().optional()
2331
+ }).strict();
2332
+ var SITE_META_SOURCES = ["shopify", "wordpress", "webonly"];
2333
+ var SiteMetaSourceSchema = import_zod20.z.enum(SITE_META_SOURCES);
2334
+ var ScrapedPageSchema = import_zod20.z.object({
2335
+ url: import_zod20.z.string().min(1, "URL de la pagina scrappeada (no vacia)"),
2336
+ title: import_zod20.z.string().nullable().optional(),
2337
+ metaDescription: import_zod20.z.string().nullable().optional(),
2338
+ ogTitle: import_zod20.z.string().nullable().optional(),
2339
+ ogDescription: import_zod20.z.string().nullable().optional(),
2340
+ h1: import_zod20.z.string().nullable().optional(),
2341
+ h2s: import_zod20.z.array(import_zod20.z.string()).default([]),
2342
+ firstParagraph: import_zod20.z.string().nullable().optional(),
2343
+ canonicalUrl: import_zod20.z.string().nullable().optional(),
2344
+ fetchedAt: import_zod20.z.string()
2345
+ }).strict();
2346
+ var SiteMetaSchema = import_zod20.z.object({
2347
+ schemaVersion: import_zod20.z.literal(1).default(1),
2348
+ source: SiteMetaSourceSchema,
2349
+ /** Hostname del shop (ej: "atteyo-com.myshopify.com" o "atteyo.com") */
2350
+ shop: import_zod20.z.string().min(1, "Hostname del shop"),
2351
+ fetchedAt: import_zod20.z.string(),
2352
+ sitemapFound: import_zod20.z.boolean(),
2353
+ urlsDiscovered: import_zod20.z.number().int().nonnegative(),
2354
+ urlsScraped: import_zod20.z.number().int().nonnegative(),
2355
+ /** Homepage parseada (puede ser null si fall el fetch). */
2356
+ homepage: ScrapedPageSchema.nullable().optional(),
2357
+ /** Paginas extra detectadas via sitemap.xml: about, faq, contact, terms, privacy, etc. */
2358
+ extraPages: import_zod20.z.record(import_zod20.z.string(), ScrapedPageSchema).default({}),
2359
+ /** Texto combinado de homepage + extras para embedding texto. */
2360
+ combinedText: import_zod20.z.string().nullable().optional(),
2361
+ /** Embedding 1408d Vertex (texto). */
2362
+ embeddingText: import_zod20.z.array(import_zod20.z.number()).length(EMBEDDING_DIMS).nullable().optional(),
2363
+ /** Embedding 1408d Vertex (imagen — opcional). */
2364
+ embeddingImage: import_zod20.z.array(import_zod20.z.number()).length(EMBEDDING_DIMS).nullable().optional(),
2365
+ embeddingModelo: import_zod20.z.string().nullable().optional(),
2366
+ /** Firestore Timestamp / Date / string ISO — flexible. */
2367
+ embeddingFecha: import_zod20.z.unknown().nullable().optional()
2368
+ }).strict();
2369
+ var GSC_CHANGE_TYPES = [
2370
+ "publish",
2371
+ "urlChange",
2372
+ "bodyRewrite",
2373
+ "metaEdit",
2374
+ "altText",
2375
+ "internalLinks"
2376
+ ];
2377
+ var GscChangeTypeSchema = import_zod21.z.enum(GSC_CHANGE_TYPES);
2378
+ var GSC_PRIORITIES = ["high", "medium", "low", "skip"];
2379
+ var GscPrioritySchema = import_zod21.z.enum(GSC_PRIORITIES);
2380
+ var GSC_STATUSES = ["pending", "flushed", "error", "skipped"];
2381
+ var GscStatusSchema = import_zod21.z.enum(GSC_STATUSES);
2382
+ var GscQueueItemSchema = import_zod21.z.object({
2383
+ schemaVersion: import_zod21.z.literal(1).default(1),
2384
+ id: import_zod21.z.string().min(1),
2385
+ tenantId: import_zod21.z.string().min(1),
2386
+ brandId: import_zod21.z.string().min(1),
2387
+ url: import_zod21.z.string().url("URL absoluta del recurso a notificar a GSC"),
2388
+ changeType: GscChangeTypeSchema,
2389
+ priority: GscPrioritySchema,
2390
+ /** Firestore Timestamp / Date / ISO — set por enqueueGscPing helper. */
2391
+ enqueuedAt: import_zod21.z.unknown(),
2392
+ status: GscStatusSchema,
2393
+ /** Set cuando flushGscQueue procesa el item (status='flushed' o 'skipped'). */
2394
+ flushedAt: import_zod21.z.unknown().nullable(),
2395
+ errorMessage: import_zod21.z.string().nullable()
2396
+ }).strict();
2397
+ var BlogSchema = import_zod22.z.object({
2398
+ schemaVersion: import_zod22.z.literal(1).default(1),
2399
+ source: SourceSchema,
2400
+ platformId: import_zod22.z.string().min(1, "ID del blog en la plataforma origen (Shopify blog ID)"),
2401
+ handle: import_zod22.z.string().min(1, "Slug/handle del blog"),
2402
+ title: import_zod22.z.string().min(1, "Titulo del blog"),
2403
+ /** Si los articles aceptan comentarios. Shopify: 'no' | 'moderate' | 'yes' */
2404
+ commentable: import_zod22.z.string().nullable().optional(),
2405
+ feedburner: import_zod22.z.string().nullable().optional(),
2406
+ tags: import_zod22.z.string().nullable().optional(),
2407
+ templateSuffix: import_zod22.z.string().nullable().optional(),
2408
+ /** Conteo de articles publicados (derivado al fetch — no oficial Shopify). */
2409
+ articlesCount: import_zod22.z.number().int().nonnegative().nullable().optional(),
2410
+ publishedAt: import_zod22.z.string().nullable().optional(),
2411
+ updatedAt: import_zod22.z.string().nullable().optional(),
2412
+ createdAt: import_zod22.z.string().nullable().optional()
2413
+ }).strict();
2414
+ var LocationSchema = import_zod23.z.object({
2415
+ schemaVersion: import_zod23.z.literal(1).default(1),
2416
+ source: SourceSchema,
2417
+ platformId: import_zod23.z.string().min(1, "ID de la location en la plataforma origen"),
2418
+ name: import_zod23.z.string().min(1, "Nombre de la location"),
2419
+ address1: import_zod23.z.string().nullable().optional(),
2420
+ address2: import_zod23.z.string().nullable().optional(),
2421
+ city: import_zod23.z.string().nullable().optional(),
2422
+ province: import_zod23.z.string().nullable().optional(),
2423
+ provinceCode: import_zod23.z.string().nullable().optional(),
2424
+ zip: import_zod23.z.string().nullable().optional(),
2425
+ country: import_zod23.z.string().nullable().optional(),
2426
+ countryCode: import_zod23.z.string().nullable().optional(),
2427
+ countryName: import_zod23.z.string().nullable().optional(),
2428
+ phone: import_zod23.z.string().nullable().optional(),
2429
+ active: import_zod23.z.boolean().nullable().optional(),
2430
+ legacy: import_zod23.z.boolean().nullable().optional(),
2431
+ createdAt: import_zod23.z.string().nullable().optional(),
2432
+ updatedAt: import_zod23.z.string().nullable().optional()
2433
+ }).strict();
2434
+ var StaffMemberSchema = import_zod24.z.object({
2435
+ schemaVersion: import_zod24.z.literal(1).default(1),
2436
+ source: SourceSchema,
2437
+ platformId: import_zod24.z.string().min(1, "ID del staff member en la plataforma origen (Shopify GID o numeric ID)"),
2438
+ /** Shopify GraphQL devuelve "name" combinado (ya no first_name/last_name separados). */
2439
+ name: import_zod24.z.string().min(1, "Nombre del staff member"),
2440
+ email: import_zod24.z.string().nullable().optional(),
2441
+ /** Shopify GraphQL: isShopOwner indica si es el dueno principal. */
2442
+ isShopOwner: import_zod24.z.boolean().nullable().optional(),
2443
+ active: import_zod24.z.boolean().nullable().optional(),
2444
+ accountType: import_zod24.z.string().nullable().optional(),
2445
+ role: import_zod24.z.string().nullable().optional(),
2446
+ locale: import_zod24.z.string().nullable().optional()
2447
+ }).strict();
2448
+ var ShopInfoSchema = import_zod25.z.object({
2449
+ shopId: import_zod25.z.union([import_zod25.z.string(), import_zod25.z.number()]).nullable().optional(),
2450
+ name: import_zod25.z.string().nullable().optional(),
2451
+ email: import_zod25.z.string().nullable().optional(),
2452
+ description: import_zod25.z.string().nullable().optional(),
2453
+ currency: import_zod25.z.string().nullable().optional(),
2454
+ moneyFormat: import_zod25.z.string().nullable().optional(),
2455
+ timezone: import_zod25.z.string().nullable().optional(),
2456
+ country: import_zod25.z.string().nullable().optional(),
2457
+ countryCode: import_zod25.z.string().nullable().optional(),
2458
+ province: import_zod25.z.string().nullable().optional(),
2459
+ city: import_zod25.z.string().nullable().optional(),
2460
+ address1: import_zod25.z.string().nullable().optional(),
2461
+ phone: import_zod25.z.string().nullable().optional(),
2462
+ domain: import_zod25.z.string().nullable().optional(),
2463
+ myshopifyDomain: import_zod25.z.string().nullable().optional(),
2464
+ planName: import_zod25.z.string().nullable().optional(),
2465
+ planDisplayName: import_zod25.z.string().nullable().optional(),
2466
+ weightUnit: import_zod25.z.string().nullable().optional(),
2467
+ primaryLocale: import_zod25.z.string().nullable().optional(),
2468
+ createdAt: import_zod25.z.string().nullable().optional(),
2469
+ updatedAt: import_zod25.z.string().nullable().optional()
2470
+ }).strict().nullable();
2471
+ var ThemeSchema = import_zod25.z.object({
2472
+ id: import_zod25.z.union([import_zod25.z.string(), import_zod25.z.number()]).nullable().optional(),
2473
+ name: import_zod25.z.string().nullable().optional(),
2474
+ role: import_zod25.z.string().nullable().optional(),
2475
+ /** Mapa de menus asignados al tema (header, footer). */
2476
+ themeMenus: import_zod25.z.object({
2477
+ header: import_zod25.z.array(import_zod25.z.object({ handle: import_zod25.z.string() }).passthrough()).default([]),
2478
+ footer: import_zod25.z.array(import_zod25.z.object({ handle: import_zod25.z.string() }).passthrough()).default([])
2479
+ }).strict().nullable().optional(),
2480
+ themeMenuHandle: import_zod25.z.string().nullable().optional(),
2481
+ /** Settings del header section (sub-objeto del theme settings). */
2482
+ headerSettings: import_zod25.z.unknown().nullable().optional(),
2483
+ footerSettings: import_zod25.z.unknown().nullable().optional()
2484
+ }).strict().nullable();
2485
+ var OrdersSummarySchema = import_zod25.z.object({
2486
+ totalOrders: import_zod25.z.number().int().nonnegative().default(0),
2487
+ dateRange: import_zod25.z.object({
2488
+ from: import_zod25.z.string(),
2489
+ to: import_zod25.z.string()
2490
+ }).strict().nullable().optional(),
2491
+ topSkus: import_zod25.z.array(import_zod25.z.object({
2492
+ sku: import_zod25.z.string(),
2493
+ title: import_zod25.z.string().nullable().optional(),
2494
+ quantity: import_zod25.z.number().nonnegative(),
2495
+ revenue: import_zod25.z.number().nonnegative()
2496
+ }).strict()).default([]),
2497
+ avgOrderValue: import_zod25.z.number().nonnegative().default(0),
2498
+ ordersPerDay: import_zod25.z.number().nonnegative().default(0),
2499
+ totalRevenue: import_zod25.z.number().nonnegative().default(0),
2500
+ financialStatusBreakdown: import_zod25.z.record(import_zod25.z.string(), import_zod25.z.number().nonnegative()).default({})
2501
+ }).strict().nullable();
2502
+ var CustomersSummarySchema = import_zod25.z.object({
2503
+ totalCustomers: import_zod25.z.number().int().nonnegative().default(0),
2504
+ topCustomers: import_zod25.z.array(import_zod25.z.object({
2505
+ id: import_zod25.z.union([import_zod25.z.string(), import_zod25.z.number()]).nullable().optional(),
2506
+ name: import_zod25.z.string().nullable().optional(),
2507
+ ordersCount: import_zod25.z.number().int().nonnegative().nullable().optional(),
2508
+ totalSpent: import_zod25.z.union([import_zod25.z.string(), import_zod25.z.number()]).nullable().optional(),
2509
+ tags: import_zod25.z.string().nullable().optional(),
2510
+ createdAt: import_zod25.z.string().nullable().optional()
2511
+ }).strict()).default([])
2512
+ }).strict().nullable();
2513
+ var SnapshotCountsSchema = import_zod25.z.object({
2514
+ products: import_zod25.z.number().int().nonnegative().default(0),
2515
+ collections: import_zod25.z.number().int().nonnegative().default(0),
2516
+ articles: import_zod25.z.number().int().nonnegative().default(0),
2517
+ pages: import_zod25.z.number().int().nonnegative().default(0),
2518
+ menus: import_zod25.z.number().int().nonnegative().default(0),
2519
+ blogs: import_zod25.z.number().int().nonnegative().default(0),
2520
+ locations: import_zod25.z.number().int().nonnegative().default(0),
2521
+ staffMembers: import_zod25.z.number().int().nonnegative().default(0),
2522
+ siteMeta: import_zod25.z.number().int().nonnegative().default(0)
2523
+ }).strict();
2524
+ var SnapshotMetaSchema = import_zod25.z.object({
2525
+ schemaVersion: import_zod25.z.literal(1).default(1),
2526
+ source: SourceSchema,
2527
+ brandId: import_zod25.z.string().min(1),
2528
+ /** Hostname del shop (atteyo.com / atteyo-com.myshopify.com). */
2529
+ shop: import_zod25.z.string().min(1),
2530
+ shopName: import_zod25.z.string().nullable().optional(),
2531
+ /** Timestamp de la ultima sync exitosa. Firestore Timestamp/Date/string ISO. */
2532
+ syncedAt: import_zod25.z.unknown().nullable().optional(),
2533
+ /** ID del ultimo import que populo este snapshot. */
2534
+ lastImportId: import_zod25.z.string().nullable().optional(),
2535
+ counts: SnapshotCountsSchema,
2536
+ shopInfo: ShopInfoSchema,
2537
+ theme: ThemeSchema,
2538
+ ordersSummary: OrdersSummarySchema,
2539
+ customersSummary: CustomersSummarySchema
2540
+ }).strict();
2003
2541
 
2004
2542
  // src/tools/core.ts
2005
2543
  function registerCoreTools(server, session) {
@@ -2007,19 +2545,18 @@ function registerCoreTools(server, session) {
2007
2545
  "get_business_summary",
2008
2546
  "Resumen general del negocio: metricas clave, contenido pendiente, estado de conexiones, alertas.",
2009
2547
  {
2010
- brandId: import_zod14.z.string().optional().describe("ID de la brand. Si no se pasa, usa la brand del contexto.")
2548
+ brandId: import_zod26.z.string().optional().describe("ID de la brand. Si no se pasa, usa la brand del contexto.")
2011
2549
  },
2012
2550
  async ({ brandId: inputBrandId }) => {
2013
2551
  const tenantId = session.requireTenant();
2014
2552
  const brandId = inputBrandId ?? session.brandId;
2015
- const config = await readDoc("marketing_config", tenantId);
2016
- if (!config) {
2017
- return { content: [{ type: "text", text: JSON.stringify({ error: "No hay marketing_config para este tenant" }) }] };
2018
- }
2019
- const brands = config.brands ?? {};
2020
2553
  if (!brandId) {
2021
- const brandList = Object.entries(brands).map(([id, b]) => ({
2022
- id,
2554
+ const brandsList = await queryByTenant(session, `tenants/${tenantId}/marketing_config`);
2555
+ if (brandsList.length === 0) {
2556
+ return { content: [{ type: "text", text: JSON.stringify({ error: "No hay marketing_config para este tenant" }) }] };
2557
+ }
2558
+ const brandListSummary = brandsList.map((b) => ({
2559
+ id: b.id,
2023
2560
  nombre: b.nombre,
2024
2561
  dominio: b.dominio
2025
2562
  }));
@@ -2028,13 +2565,13 @@ function registerCoreTools(server, session) {
2028
2565
  type: "text",
2029
2566
  text: JSON.stringify({
2030
2567
  mensaje: "No hay brand seleccionada. Brands disponibles:",
2031
- brands: brandList,
2568
+ brands: brandListSummary,
2032
2569
  instruccion: "Usa set_context o pasa brandId al tool."
2033
2570
  })
2034
2571
  }]
2035
2572
  };
2036
2573
  }
2037
- const brand = brands[brandId];
2574
+ const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
2038
2575
  if (!brand) {
2039
2576
  return { content: [{ type: "text", text: JSON.stringify({ error: `Brand "${brandId}" no existe` }) }] };
2040
2577
  }
@@ -2084,7 +2621,7 @@ function registerCoreTools(server, session) {
2084
2621
  "get_pending_actions",
2085
2622
  "Lista todo lo que necesita atencion: contenido por aprobar, fotos sin procesar.",
2086
2623
  {
2087
- brandId: import_zod14.z.string().optional().describe("ID de la brand")
2624
+ brandId: import_zod26.z.string().optional().describe("ID de la brand")
2088
2625
  },
2089
2626
  async ({ brandId: inputBrandId }) => {
2090
2627
  const tenantId = session.requireTenant();
@@ -2099,9 +2636,7 @@ function registerCoreTools(server, session) {
2099
2636
  { field: "estado", op: "==", value: ESTADO_FOTO.NUEVA }
2100
2637
  ])
2101
2638
  ]);
2102
- const config = await readDoc("marketing_config", tenantId);
2103
- const brands = config?.brands ?? {};
2104
- const brand = brands[brandId];
2639
+ const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
2105
2640
  const seo = brand?.seoSnapshot;
2106
2641
  const result = {
2107
2642
  contenidoPorAprobar: contenidoPendiente.map((c) => ({
@@ -2128,9 +2663,9 @@ function registerCoreTools(server, session) {
2128
2663
  "execute_action",
2129
2664
  "Ejecuta una accion sobre contenido o foto: aprobar, rechazar, descartar foto, usar foto tal cual.",
2130
2665
  {
2131
- accion: import_zod14.z.enum(["aprobar", "rechazar", "descartar_foto", "usar_foto_tal_cual"]).describe("Accion a ejecutar"),
2132
- targetId: import_zod14.z.string().describe("ID del contenido o foto"),
2133
- motivo: import_zod14.z.string().optional().describe("Motivo del rechazo (requerido para rechazar)")
2666
+ accion: import_zod26.z.enum(["aprobar", "rechazar", "descartar_foto", "usar_foto_tal_cual"]).describe("Accion a ejecutar"),
2667
+ targetId: import_zod26.z.string().describe("ID del contenido o foto"),
2668
+ motivo: import_zod26.z.string().optional().describe("Motivo del rechazo (requerido para rechazar)")
2134
2669
  },
2135
2670
  async ({ accion, targetId, motivo }) => {
2136
2671
  session.requireTenant();
@@ -2191,17 +2726,31 @@ function registerCoreTools(server, session) {
2191
2726
  }
2192
2727
 
2193
2728
  // src/tools/marketing.ts
2194
- var import_zod17 = require("zod");
2729
+ var import_zod37 = require("zod");
2195
2730
 
2196
2731
  // ../packages/marketing-business-logic/dist/index.js
2197
2732
  var import_firebase_admin2 = require("firebase-admin");
2198
2733
  var import_firebase_admin3 = require("firebase-admin");
2734
+ var import_zod27 = require("zod");
2735
+ var import_zod28 = require("zod");
2736
+ var import_zod29 = require("zod");
2199
2737
  var import_firebase_admin4 = require("firebase-admin");
2200
2738
  var import_firebase_admin5 = require("firebase-admin");
2201
2739
  var import_firebase_admin6 = require("firebase-admin");
2740
+ var import_zod30 = require("zod");
2741
+ var import_zod31 = require("zod");
2202
2742
  var import_firebase_admin7 = require("firebase-admin");
2743
+ var import_zod32 = require("zod");
2203
2744
  var import_firebase_admin8 = require("firebase-admin");
2204
2745
  var import_firebase_admin9 = require("firebase-admin");
2746
+ var import_firestore3 = require("firebase-admin/firestore");
2747
+ var import_firestore4 = require("firebase-admin/firestore");
2748
+ var import_firestore5 = require("firebase-admin/firestore");
2749
+ var import_firestore6 = require("firebase-admin/firestore");
2750
+ var import_zod33 = require("zod");
2751
+ var import_firestore7 = require("firebase-admin/firestore");
2752
+ var import_zod34 = require("zod");
2753
+ var import_firestore8 = require("firebase-admin/firestore");
2205
2754
  var RULE_NEGATIVES = {
2206
2755
  allowFaces: "no people, no faces, no hands",
2207
2756
  allowProductTransform: "no distorted products, no warped objects",
@@ -2351,14 +2900,9 @@ async function brandBriefWriter(input) {
2351
2900
  hint: "Revisa el ejemplo y corrige el campo indicado en path. Manda objeto/array nativo, NO string JSON."
2352
2901
  };
2353
2902
  }
2354
- const configRef = db.collection("marketing_config").doc(tenantId);
2355
- const configSnap = await configRef.get();
2356
- if (!configSnap.exists) {
2357
- return { ok: false, error: "No hay marketing_config" };
2358
- }
2359
- const config = configSnap.data();
2360
- const brands = config?.brands ?? {};
2361
- if (!brands[brandId]) {
2903
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2904
+ const brandSnap = await brandRef.get();
2905
+ if (!brandSnap.exists) {
2362
2906
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
2363
2907
  }
2364
2908
  const brief = validation.parsed;
@@ -2370,10 +2914,14 @@ async function brandBriefWriter(input) {
2370
2914
  validadoAt: null,
2371
2915
  _validatedFields: brief._validatedFields ?? {}
2372
2916
  };
2373
- await configRef.update({
2374
- [`brands.${brandId}.brandBrief`]: briefWithMeta,
2917
+ await brandRef.set({
2918
+ brandBrief: briefWithMeta,
2919
+ id: brandId,
2920
+ brandId,
2921
+ tenantId,
2922
+ schemaVersion: 1,
2375
2923
  updatedAt: import_firebase_admin2.firestore.FieldValue.serverTimestamp()
2376
- });
2924
+ }, { merge: true });
2377
2925
  return {
2378
2926
  ok: true,
2379
2927
  mensaje: `Brand Brief guardado para "${brandId}". El tenant puede validar los campos en Config > Mi Negocio.`
@@ -2381,14 +2929,9 @@ async function brandBriefWriter(input) {
2381
2929
  }
2382
2930
  async function save(input) {
2383
2931
  const { db, tenantId, brandId, plan } = input;
2384
- const configRef = db.collection("marketing_config").doc(tenantId);
2385
- const configSnap = await configRef.get();
2386
- if (!configSnap.exists) {
2387
- return { ok: false, error: "No hay marketing_config" };
2388
- }
2389
- const config = configSnap.data();
2390
- const brands = config.brands ?? {};
2391
- if (!brands[brandId]) {
2932
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2933
+ const brandSnap = await brandRef.get();
2934
+ if (!brandSnap.exists) {
2392
2935
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
2393
2936
  }
2394
2937
  const { blogStrategy, ...planSinBlogStrategy } = plan;
@@ -2397,18 +2940,15 @@ async function save(input) {
2397
2940
  fechaCreacion: (/* @__PURE__ */ new Date()).toISOString(),
2398
2941
  creadoPorId: "mcp-cowork"
2399
2942
  };
2400
- const updatedBrands = { ...brands };
2401
- updatedBrands[brandId] = {
2402
- ...brands[brandId],
2943
+ await brandRef.set({
2403
2944
  plan: planWithMeta,
2404
- ...blogStrategy ? { blogStrategy } : {}
2405
- };
2406
- await configRef.set({
2407
- id: config.id,
2945
+ ...blogStrategy ? { blogStrategy } : {},
2946
+ id: brandId,
2947
+ brandId,
2408
2948
  tenantId,
2409
- brands: updatedBrands,
2949
+ schemaVersion: 1,
2410
2950
  updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
2411
- });
2951
+ }, { merge: true });
2412
2952
  return {
2413
2953
  ok: true,
2414
2954
  mensaje: `Plan de marketing guardado para brand "${brandId}"` + (blogStrategy ? " (incluye blogStrategy)" : "")
@@ -2416,16 +2956,12 @@ async function save(input) {
2416
2956
  }
2417
2957
  async function updateField(input) {
2418
2958
  const { db, tenantId, brandId, field, value } = input;
2419
- const configRef = db.collection("marketing_config").doc(tenantId);
2420
- const configSnap = await configRef.get();
2421
- if (!configSnap.exists) {
2422
- return { ok: false, error: "No hay marketing_config" };
2423
- }
2424
- const config = configSnap.data();
2425
- const brands = config.brands ?? {};
2426
- if (!brands[brandId]) {
2959
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2960
+ const brandSnap = await brandRef.get();
2961
+ if (!brandSnap.exists) {
2427
2962
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
2428
2963
  }
2964
+ const brandData = brandSnap.data();
2429
2965
  let parsedValue = value;
2430
2966
  if (typeof value === "string") {
2431
2967
  const trimmed = value.trim();
@@ -2448,27 +2984,24 @@ async function updateField(input) {
2448
2984
  hint: "Manda el valor como objeto/array nativo, NO como string JSON. Usa el ejemplo arriba como referencia exacta del shape esperado."
2449
2985
  };
2450
2986
  }
2451
- const updatedBrands = { ...brands };
2987
+ const updatePayload = {
2988
+ id: brandId,
2989
+ brandId,
2990
+ tenantId,
2991
+ schemaVersion: 1,
2992
+ updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
2993
+ };
2452
2994
  if (field === "blogStrategy") {
2453
- updatedBrands[brandId] = {
2454
- ...brands[brandId],
2455
- blogStrategy: validation.parsed
2456
- };
2995
+ updatePayload.blogStrategy = validation.parsed;
2457
2996
  } else {
2458
- const currentPlan = brands[brandId].plan ?? {};
2459
- const updatedPlan = {
2997
+ const currentPlan = brandData.plan ?? {};
2998
+ updatePayload.plan = {
2460
2999
  ...currentPlan,
2461
3000
  [field]: validation.parsed,
2462
3001
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2463
3002
  };
2464
- updatedBrands[brandId] = { ...brands[brandId], plan: updatedPlan };
2465
3003
  }
2466
- await configRef.set({
2467
- id: config.id,
2468
- tenantId,
2469
- brands: updatedBrands,
2470
- updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
2471
- });
3004
+ await brandRef.set(updatePayload, { merge: true });
2472
3005
  return {
2473
3006
  ok: true,
2474
3007
  mensaje: `Campo "${field}" actualizado en el plan de "${brandId}"`
@@ -2478,6 +3011,225 @@ var planWriter = {
2478
3011
  save,
2479
3012
  updateField
2480
3013
  };
3014
+ var DisabledReasonCodeSchema = import_zod29.z.enum([
3015
+ "paywall",
3016
+ // Acción requiere upgrade de plan
3017
+ "oauth_missing",
3018
+ // Falta conectar servicio externo (Shopify, GBP, etc.)
3019
+ "data_missing",
3020
+ // Falta información necesaria (brand brief, foto, etc.)
3021
+ "plan_not_includes",
3022
+ // Plan actual no incluye la feature
3023
+ "feature_disabled",
3024
+ // Feature deshabilitada admin-side para este tenant
3025
+ "other"
3026
+ // Caso explícito fuera del enum. Detail obligatorio.
3027
+ ]);
3028
+ var DisabledOutputSchema = import_zod29.z.object({
3029
+ disabled: import_zod29.z.literal(true),
3030
+ code: DisabledReasonCodeSchema,
3031
+ /**
3032
+ * Variables para interpolar en el template del mensaje (`{key}` → value).
3033
+ * Ejemplo: { service: 'Shopify' } para `oauth_missing`.
3034
+ * Para `other`, requiere al menos { detail: '...' } para que el template
3035
+ * '{detail}' interpole.
3036
+ */
3037
+ detail: import_zod29.z.record(import_zod29.z.string(), import_zod29.z.string()).optional()
3038
+ });
3039
+ var TEMPLATES = {
3040
+ es: {
3041
+ paywall: "Esta acci\xF3n requiere actualizar tu plan.",
3042
+ oauth_missing: "Necesitas conectar {service} primero.",
3043
+ data_missing: "Falta informaci\xF3n: {detail}",
3044
+ plan_not_includes: "Tu plan no incluye {feature}",
3045
+ feature_disabled: "Esta funci\xF3n est\xE1 deshabilitada para tu cuenta.",
3046
+ other: "{detail}"
3047
+ },
3048
+ en: {
3049
+ paywall: "This action requires upgrading your plan.",
3050
+ oauth_missing: "You need to connect {service} first.",
3051
+ data_missing: "Missing information: {detail}",
3052
+ plan_not_includes: "Your plan doesn't include {feature}",
3053
+ feature_disabled: "This feature is disabled for your account.",
3054
+ other: "{detail}"
3055
+ }
3056
+ };
3057
+ function getDisabledMessage(code, locale, vars) {
3058
+ if (!(locale in TEMPLATES)) {
3059
+ throw new Error(
3060
+ `Locale '${locale}' no soportado en disabledMessages. Locales activos: ${Object.keys(TEMPLATES).join(", ")}.`
3061
+ );
3062
+ }
3063
+ const template = TEMPLATES[locale][code];
3064
+ if (!template) {
3065
+ throw new Error(
3066
+ `Disabled code '${code}' no tiene template definido para locale '${locale}'.`
3067
+ );
3068
+ }
3069
+ return template.replace(/\{(\w+)\}/g, (_match, key) => {
3070
+ if (!vars || !(key in vars)) {
3071
+ throw new Error(
3072
+ `getDisabledMessage: variable '${key}' del template '${code}' (${locale}) no provista en vars. Helper debe pasar { ${key}: '...' } en detail.`
3073
+ );
3074
+ }
3075
+ return vars[key];
3076
+ });
3077
+ }
3078
+ var SideEffectEnum = import_zod28.z.enum([
3079
+ "reads_firestore",
3080
+ "writes_firestore",
3081
+ "updates_calendar_slot",
3082
+ "updates_brand_config",
3083
+ "modifies_quota_state",
3084
+ "spends_credits",
3085
+ "consumes_ai_tokens",
3086
+ "sends_email",
3087
+ "publishes_external"
3088
+ ]);
3089
+ var WRITER_EFFECTS = [
3090
+ "writes_firestore",
3091
+ "updates_calendar_slot",
3092
+ "updates_brand_config",
3093
+ "modifies_quota_state",
3094
+ "publishes_external"
3095
+ ];
3096
+ var SummaryTemplateSchema = import_zod28.z.custom((val) => typeof val === "function", {
3097
+ message: "martinSummaryTemplate debe ser funci\xF3n (input, output, locale) => string"
3098
+ });
3099
+ var ConfirmationTemplateSchema = import_zod28.z.custom((val) => typeof val === "function", {
3100
+ message: "martinConfirmationTemplate debe ser funci\xF3n (input, locale) => string"
3101
+ });
3102
+ var ExtractTargetPathSchema = import_zod28.z.custom((val) => typeof val === "function", {
3103
+ message: "extractTargetPath debe ser funci\xF3n (input, output) => string"
3104
+ });
3105
+ var ExtractChangesSchema = import_zod28.z.custom((val) => typeof val === "function", {
3106
+ message: "extractChanges debe ser funci\xF3n (input, output) => { before, after }"
3107
+ });
3108
+ var MartinContractSchema = import_zod28.z.object({
3109
+ // Schema versioning (Dim 4 backbone)
3110
+ schemaVersion: import_zod28.z.literal(1).default(1),
3111
+ // Identidad
3112
+ name: import_zod28.z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, "snake_case requerido (ej: descartar_contenido)"),
3113
+ description: import_zod28.z.string().min(10),
3114
+ // Validación I/O del helper
3115
+ paramsSchema: import_zod28.z.instanceof(import_zod28.z.ZodType),
3116
+ outputSchema: import_zod28.z.instanceof(import_zod28.z.ZodType),
3117
+ // Reglas de comportamiento
3118
+ requiresConfirmation: import_zod28.z.boolean(),
3119
+ requiresDoubleConfirmation: import_zod28.z.boolean().optional(),
3120
+ destructive: import_zod28.z.boolean(),
3121
+ affectsPublication: import_zod28.z.boolean(),
3122
+ affectsExternal: import_zod28.z.boolean(),
3123
+ // Presentación al usuario (i18n)
3124
+ martinSummaryTemplate: SummaryTemplateSchema,
3125
+ martinConfirmationTemplate: ConfirmationTemplateSchema.optional(),
3126
+ // Auditoría
3127
+ auditAction: import_zod28.z.string().regex(/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_.]*$/, "formato dominio.accion (ej: contenido.descartar)"),
3128
+ extractTargetPath: ExtractTargetPathSchema.optional(),
3129
+ extractChanges: ExtractChangesSchema.optional(),
3130
+ // Governance
3131
+ quotasConsumed: import_zod28.z.array(import_zod28.z.string()),
3132
+ requiredRoles: import_zod28.z.array(import_zod28.z.string()).min(1),
3133
+ sideEffects: import_zod28.z.array(SideEffectEnum),
3134
+ // Disabled state declaration (HITO 6).
3135
+ // Si el helper PUEDE retornar { disabled: true, code }, el author
3136
+ // declara aquí los códigos posibles. El wrapper valida en runtime
3137
+ // que el code retornado por el helper esté declarado — si no, throws.
3138
+ // Sin esto declarado, el wrapper trata cualquier code como inválido.
3139
+ disabledReasonCodes: import_zod28.z.array(DisabledReasonCodeSchema).optional()
3140
+ }).superRefine((contract, ctx) => {
3141
+ const isWriter = contract.sideEffects.some((e) => WRITER_EFFECTS.includes(e));
3142
+ if (isWriter && !contract.extractTargetPath) {
3143
+ ctx.addIssue({
3144
+ code: "custom",
3145
+ message: `Contract "${contract.name}" tiene side effects de escritura \u2014 extractTargetPath es obligatorio`,
3146
+ path: ["extractTargetPath"]
3147
+ });
3148
+ }
3149
+ if (isWriter && !contract.extractChanges) {
3150
+ ctx.addIssue({
3151
+ code: "custom",
3152
+ message: `Contract "${contract.name}" tiene side effects de escritura \u2014 extractChanges es obligatorio`,
3153
+ path: ["extractChanges"]
3154
+ });
3155
+ }
3156
+ if (contract.requiresConfirmation && !contract.martinConfirmationTemplate) {
3157
+ ctx.addIssue({
3158
+ code: "custom",
3159
+ message: `Contract "${contract.name}" requiere confirmaci\xF3n \u2014 martinConfirmationTemplate es obligatorio`,
3160
+ path: ["martinConfirmationTemplate"]
3161
+ });
3162
+ }
3163
+ if (contract.requiresDoubleConfirmation && !contract.requiresConfirmation) {
3164
+ ctx.addIssue({
3165
+ code: "custom",
3166
+ message: `Contract "${contract.name}" requiere doble confirmaci\xF3n pero requiresConfirmation es false`,
3167
+ path: ["requiresDoubleConfirmation"]
3168
+ });
3169
+ }
3170
+ if (contract.destructive && !contract.requiresConfirmation) {
3171
+ ctx.addIssue({
3172
+ code: "custom",
3173
+ message: `Contract "${contract.name}" es destructivo pero NO requiere confirmaci\xF3n`,
3174
+ path: ["requiresConfirmation"]
3175
+ });
3176
+ }
3177
+ });
3178
+ var ParamsSchema = import_zod27.z.object({
3179
+ tenantId: import_zod27.z.string().min(1),
3180
+ brandId: import_zod27.z.string().min(1),
3181
+ /** Plan completo generado por Claude. Puede incluir blogStrategy. */
3182
+ plan: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.unknown())
3183
+ });
3184
+ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3185
+ import_zod27.z.object({
3186
+ ok: import_zod27.z.literal(true),
3187
+ mensaje: import_zod27.z.string()
3188
+ }),
3189
+ import_zod27.z.object({
3190
+ ok: import_zod27.z.literal(false),
3191
+ error: import_zod27.z.string(),
3192
+ detalle: import_zod27.z.unknown().optional(),
3193
+ path: import_zod27.z.string().optional(),
3194
+ recibido: import_zod27.z.unknown().optional(),
3195
+ ejemplo_correcto: import_zod27.z.unknown().optional(),
3196
+ hint: import_zod27.z.string().optional()
3197
+ })
3198
+ ]);
3199
+ var rawContract = {
3200
+ name: "save_marketing_plan",
3201
+ description: "Guarda el plan de marketing de una brand. Si el plan incluye blogStrategy, la extrae al nivel brand (no dentro de plan).",
3202
+ paramsSchema: ParamsSchema,
3203
+ outputSchema: OutputSchema,
3204
+ // No es destructivo (sobrescribe plan completo, pero plan se regenera fácil),
3205
+ // no afecta publicación, no llama externo.
3206
+ requiresConfirmation: false,
3207
+ destructive: false,
3208
+ affectsPublication: false,
3209
+ affectsExternal: false,
3210
+ martinSummaryTemplate: (input, output, locale) => {
3211
+ if (!output.ok) {
3212
+ if (locale === "en") return `I couldn't save the plan: ${output.error}`;
3213
+ return `No pude guardar el plan: ${output.error}`;
3214
+ }
3215
+ if (locale === "en") return `I saved the marketing plan for ${input.brandId}.`;
3216
+ return `Guard\xE9 el plan de marketing de ${input.brandId}.`;
3217
+ },
3218
+ // Auditoría — OBLIGATORIO porque writes_firestore + updates_brand_config.
3219
+ auditAction: "marketing.plan.guardar",
3220
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3221
+ extractChanges: (input, output) => ({
3222
+ before: null,
3223
+ // El helper no lee el plan previo en save (solo merge).
3224
+ after: output.ok ? { plan: input.plan } : null
3225
+ }),
3226
+ quotasConsumed: [],
3227
+ requiredRoles: ["admin", "encargado"],
3228
+ sideEffects: ["writes_firestore", "updates_brand_config"]
3229
+ };
3230
+ var planWriterSaveContract = MartinContractSchema.parse(
3231
+ rawContract
3232
+ );
2481
3233
  async function collectionSuggestionsWriter(input) {
2482
3234
  const { db, tenantId, brandId, suggestions } = input;
2483
3235
  const validation = validateCollectionSuggestions(suggestions);
@@ -2493,18 +3245,13 @@ async function collectionSuggestionsWriter(input) {
2493
3245
  };
2494
3246
  }
2495
3247
  const validSuggestions = validation.parsed;
2496
- const configRef = db.collection("marketing_config").doc(tenantId);
2497
- const configSnap = await configRef.get();
2498
- if (!configSnap.exists) {
2499
- return { ok: false, error: "marketing_config no existe" };
2500
- }
2501
- const config = configSnap.data();
2502
- const brands = config.brands ?? {};
2503
- const brand = brands[brandId];
2504
- if (!brand) {
3248
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3249
+ const brandSnap = await brandRef.get();
3250
+ if (!brandSnap.exists) {
2505
3251
  return { ok: false, error: `Brand ${brandId} no encontrada` };
2506
3252
  }
2507
- const existing = brand.collectionSuggestions ?? {};
3253
+ const brandData = brandSnap.data();
3254
+ const existing = brandData.collectionSuggestions ?? {};
2508
3255
  const updated = { ...existing };
2509
3256
  for (const s of validSuggestions) {
2510
3257
  const id = String(s.collectionId);
@@ -2522,9 +3269,14 @@ async function collectionSuggestionsWriter(input) {
2522
3269
  generadoPor: "mcp-cowork"
2523
3270
  };
2524
3271
  }
2525
- await configRef.update({
2526
- [`brands.${brandId}.collectionSuggestions`]: updated
2527
- });
3272
+ await brandRef.set({
3273
+ collectionSuggestions: updated,
3274
+ id: brandId,
3275
+ brandId,
3276
+ tenantId,
3277
+ schemaVersion: 1,
3278
+ updatedAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp()
3279
+ }, { merge: true });
2528
3280
  return {
2529
3281
  ok: true,
2530
3282
  saved: validSuggestions.length,
@@ -2631,7 +3383,7 @@ async function contenidoUpdater(input) {
2631
3383
  estado,
2632
3384
  calendarioItemRef
2633
3385
  } = input;
2634
- const docRef = db.collection("marketing_contenido").doc(contenidoId);
3386
+ const docRef = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoId}`);
2635
3387
  const snap = await docRef.get();
2636
3388
  if (!snap.exists) {
2637
3389
  return { ok: false, error: `Contenido ${contenidoId} no existe` };
@@ -2691,12 +3443,29 @@ async function contenidoUpdater(input) {
2691
3443
  };
2692
3444
  }
2693
3445
  var CAMPOS_SEMANTICOS = ["keyword", "tema", "plataforma", "tipo"];
3446
+ function mapContenidoEstadoToSlotEstado(contenidoEstado) {
3447
+ switch (contenidoEstado) {
3448
+ case "pendiente_aprobacion":
3449
+ case "generado":
3450
+ case "editado":
3451
+ return "generado";
3452
+ case "aprobado":
3453
+ return "aprobado";
3454
+ case "publicado":
3455
+ return "publicado";
3456
+ case "rechazado":
3457
+ return "rechazado";
3458
+ // borrador + error_publicacion + estados desconocidos → no tocar slot
3459
+ default:
3460
+ return null;
3461
+ }
3462
+ }
2694
3463
  async function calendarSlotUpdater(input) {
2695
3464
  const { db, tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente } = input;
2696
3465
  if (slotIndex < 0) {
2697
3466
  return { ok: false, error: "slotIndex no puede ser negativo" };
2698
3467
  }
2699
- const calQuery = await db.collection("marketing_calendario").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("mes", "==", mes).limit(1).get();
3468
+ const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", mes).limit(1).get();
2700
3469
  if (calQuery.empty) {
2701
3470
  return { ok: false, error: "Calendario no encontrado" };
2702
3471
  }
@@ -2733,7 +3502,7 @@ async function calendarSlotUpdater(input) {
2733
3502
  }
2734
3503
  let contenidosADescartar = [];
2735
3504
  if (oldContenidoRef && (accionContenidoExistente === "descartar" || !tocaSemantica)) {
2736
- const contenidoQuery = await db.collection("marketing_contenido").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("calendarioItemRef", "==", oldContenidoRef).limit(10).get();
3505
+ const contenidoQuery = await db.collection("tenants").doc(tenantId).collection("marketing_contenido").where("brandId", "==", brandId).where("calendarioItemRef", "==", oldContenidoRef).limit(10).get();
2737
3506
  contenidosADescartar = contenidoQuery.docs.filter((c) => {
2738
3507
  const data = c.data();
2739
3508
  return data.estado !== "descartado" && data.estado !== ESTADO_CONTENIDO.PUBLICADO;
@@ -2790,6 +3559,18 @@ async function calendarSlotUpdater(input) {
2790
3559
  targetRef: `semana:${targetSemanaNum}:slot:${targetSlotIdx}`
2791
3560
  };
2792
3561
  }
3562
+ let movedSlotEstado = null;
3563
+ if (moveTarget && oldContenidoRef) {
3564
+ try {
3565
+ const contenidoSnap = await db.doc(`tenants/${tenantId}/marketing_contenido/${oldContenidoRef}`).get();
3566
+ if (contenidoSnap.exists) {
3567
+ const estadoContenido = contenidoSnap.data().estado;
3568
+ movedSlotEstado = mapContenidoEstadoToSlotEstado(estadoContenido);
3569
+ }
3570
+ } catch {
3571
+ movedSlotEstado = null;
3572
+ }
3573
+ }
2793
3574
  let resultAction;
2794
3575
  let resultSlotIndex = slotIndex;
2795
3576
  let movedTo;
@@ -2825,10 +3606,14 @@ async function calendarSlotUpdater(input) {
2825
3606
  };
2826
3607
  const targetSemana = freshSemanas[moveTarget.targetSemanaNum - 1];
2827
3608
  const targetItems = targetSemana.items ?? [];
3609
+ const targetSlotActual = targetItems[moveTarget.targetSlotIdx];
3610
+ const targetEstadoActual = targetSlotActual.estado;
3611
+ const shouldUpdateEstado = movedSlotEstado !== null && targetEstadoActual !== "publicado";
2828
3612
  targetItems[moveTarget.targetSlotIdx] = {
2829
- ...targetItems[moveTarget.targetSlotIdx],
3613
+ ...targetSlotActual,
2830
3614
  contenidoRef: oldContenidoRef,
2831
- ...oldFotoIdAsignada ? { fotoIdAsignada: oldFotoIdAsignada } : {}
3615
+ ...oldFotoIdAsignada ? { fotoIdAsignada: oldFotoIdAsignada } : {},
3616
+ ...shouldUpdateEstado ? { estado: movedSlotEstado } : {}
2832
3617
  };
2833
3618
  targetSemana.items = targetItems;
2834
3619
  freshSemanas[moveTarget.targetSemanaNum - 1] = targetSemana;
@@ -2850,7 +3635,7 @@ async function calendarSlotUpdater(input) {
2850
3635
  });
2851
3636
  if (moveTarget && oldContenidoRef) {
2852
3637
  try {
2853
- await db.collection("marketing_contenido").doc(oldContenidoRef).update({
3638
+ await db.doc(`tenants/${tenantId}/marketing_contenido/${oldContenidoRef}`).update({
2854
3639
  calendarioItemRef: moveTarget.targetRef
2855
3640
  });
2856
3641
  } catch (err) {
@@ -2949,6 +3734,134 @@ async function handleNuevoSlot(args) {
2949
3734
  descartados: 0
2950
3735
  };
2951
3736
  }
3737
+ var ParamsSchema2 = import_zod30.z.object({
3738
+ tenantId: import_zod30.z.string().min(1),
3739
+ brandId: import_zod30.z.string().min(1),
3740
+ mes: import_zod30.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3741
+ semana: import_zod30.z.number().int().min(1).max(5),
3742
+ slotIndex: import_zod30.z.number().int().min(0),
3743
+ cambios: import_zod30.z.record(import_zod30.z.string(), import_zod30.z.unknown()),
3744
+ accionContenidoExistente: import_zod30.z.string().optional()
3745
+ });
3746
+ var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3747
+ import_zod30.z.object({
3748
+ ok: import_zod30.z.literal(true),
3749
+ action: import_zod30.z.enum(["added", "updated", "nuevo_slot", "moved"]),
3750
+ slotIndex: import_zod30.z.number().int(),
3751
+ descartados: import_zod30.z.number().int(),
3752
+ movedTo: import_zod30.z.string().optional()
3753
+ }),
3754
+ import_zod30.z.object({
3755
+ ok: import_zod30.z.literal(false),
3756
+ error: import_zod30.z.string(),
3757
+ code: import_zod30.z.enum(["ACCION_CONTENIDO_EXISTENTE_REQUIRED", "MOVE_TARGET_OCCUPIED", "MOVE_TARGET_INVALID"]).optional(),
3758
+ opciones: import_zod30.z.array(import_zod30.z.string()).optional()
3759
+ })
3760
+ ]);
3761
+ var rawContract2 = {
3762
+ name: "update_calendar_slot",
3763
+ description: "Modifica o agrega un slot del calendario editorial. Si slotIndex >= items.length agrega un slot nuevo. Si el slot ten\xEDa contenidoRef previo y los cambios tocan campos sem\xE1nticos (keyword/tema/plataforma/tipo), exige accionContenidoExistente con 4 opciones: descartar | mover:semana:N:slot:M | nuevo_slot | mantener.",
3764
+ paramsSchema: ParamsSchema2,
3765
+ outputSchema: OutputSchema2,
3766
+ requiresConfirmation: false,
3767
+ destructive: false,
3768
+ // Mutación, pero reversible (puede deshacerse cambiando el slot).
3769
+ affectsPublication: false,
3770
+ affectsExternal: false,
3771
+ martinSummaryTemplate: (input, output, locale) => {
3772
+ if (!output.ok) {
3773
+ if (locale === "en") return `I couldn't update the slot: ${output.error}`;
3774
+ return `No pude actualizar el slot: ${output.error}`;
3775
+ }
3776
+ const verb = {
3777
+ added: locale === "en" ? "added" : "agregu\xE9",
3778
+ updated: locale === "en" ? "updated" : "actualic\xE9",
3779
+ nuevo_slot: locale === "en" ? "created a new slot" : "cre\xE9 un slot nuevo",
3780
+ moved: locale === "en" ? "moved" : "mov\xED"
3781
+ }[output.action];
3782
+ if (locale === "en") {
3783
+ return `I ${verb} the slot in week ${input.semana} (${input.mes}).`;
3784
+ }
3785
+ return `${verb[0].toUpperCase() + verb.slice(1)} el slot en la semana ${input.semana} (${input.mes}).`;
3786
+ },
3787
+ auditAction: "marketing.calendario.slot.actualizar",
3788
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_calendario/(brand=${input.brandId},mes=${input.mes}).semana=${input.semana}.slot=${input.slotIndex}`,
3789
+ extractChanges: (input, output) => ({
3790
+ before: null,
3791
+ // Helper no devuelve estado previo del slot — captura el cambio aplicado.
3792
+ after: output.ok ? {
3793
+ action: output.action,
3794
+ cambios: input.cambios,
3795
+ accionContenidoExistente: input.accionContenidoExistente ?? null,
3796
+ descartados: output.descartados,
3797
+ movedTo: output.movedTo ?? null
3798
+ } : null
3799
+ }),
3800
+ quotasConsumed: [],
3801
+ requiredRoles: ["admin", "encargado"],
3802
+ sideEffects: ["writes_firestore", "updates_calendar_slot"]
3803
+ };
3804
+ var calendarSlotUpdaterContract = MartinContractSchema.parse(
3805
+ rawContract2
3806
+ );
3807
+ async function getCalendar(input) {
3808
+ const { db, tenantId, brandId, mes } = input;
3809
+ const snap = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", mes).limit(1).get();
3810
+ if (snap.empty) {
3811
+ return {
3812
+ ok: true,
3813
+ mes,
3814
+ brandId,
3815
+ calendario: null,
3816
+ mensaje: "No hay calendario para este mes. Genera un plan de marketing primero."
3817
+ };
3818
+ }
3819
+ const doc = snap.docs[0];
3820
+ return {
3821
+ ok: true,
3822
+ mes,
3823
+ brandId,
3824
+ calendario: { id: doc.id, ...doc.data() }
3825
+ };
3826
+ }
3827
+ var ParamsSchema3 = import_zod31.z.object({
3828
+ tenantId: import_zod31.z.string().min(1),
3829
+ brandId: import_zod31.z.string().min(1),
3830
+ mes: import_zod31.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM")
3831
+ });
3832
+ var OutputSchema3 = import_zod31.z.object({
3833
+ ok: import_zod31.z.literal(true),
3834
+ mes: import_zod31.z.string(),
3835
+ brandId: import_zod31.z.string(),
3836
+ calendario: import_zod31.z.record(import_zod31.z.string(), import_zod31.z.unknown()).nullable(),
3837
+ mensaje: import_zod31.z.string().optional()
3838
+ });
3839
+ var rawContract3 = {
3840
+ name: "get_calendar",
3841
+ description: "Lee el calendario editorial del mes para una brand. Retorna semanas con items planificados por plataforma.",
3842
+ paramsSchema: ParamsSchema3,
3843
+ outputSchema: OutputSchema3,
3844
+ requiresConfirmation: false,
3845
+ destructive: false,
3846
+ affectsPublication: false,
3847
+ affectsExternal: false,
3848
+ martinSummaryTemplate: (input, output, locale) => {
3849
+ if (!output.calendario) {
3850
+ if (locale === "en") return `No calendar for ${output.mes}.`;
3851
+ return `No hay calendario para ${output.mes}.`;
3852
+ }
3853
+ if (locale === "en") return `Here's the ${output.mes} calendar for ${input.brandId}.`;
3854
+ return `Aqu\xED est\xE1 el calendario de ${output.mes} para ${input.brandId}.`;
3855
+ },
3856
+ // AUDITA SIEMPRE — regla A5. Lectura no muta, changes son null/null.
3857
+ auditAction: "marketing.calendario.leer",
3858
+ quotasConsumed: [],
3859
+ requiredRoles: ["admin", "encargado", "empleado"],
3860
+ sideEffects: ["reads_firestore"]
3861
+ };
3862
+ var getCalendarContract = MartinContractSchema.parse(
3863
+ rawContract3
3864
+ );
2952
3865
  var PLATAFORMA_A_FORMATO = {
2953
3866
  gbp: "gbp_4_3",
2954
3867
  shopify_blog: "blog_3_2",
@@ -2957,7 +3870,7 @@ var PLATAFORMA_A_FORMATO = {
2957
3870
  };
2958
3871
  async function photoAssigner(input) {
2959
3872
  const { db, tenantId, brandId, contenidoRef, fotoId, calendarioItemRef } = input;
2960
- const contenidoRefDoc = db.collection("marketing_contenido").doc(contenidoRef);
3873
+ const contenidoRefDoc = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoRef}`);
2961
3874
  const contenidoSnap = await contenidoRefDoc.get();
2962
3875
  if (!contenidoSnap.exists) {
2963
3876
  return { ok: false, error: `Contenido ${contenidoRef} no encontrado` };
@@ -2966,7 +3879,7 @@ async function photoAssigner(input) {
2966
3879
  if (contenido.tenantId !== tenantId) {
2967
3880
  return { ok: false, error: "Contenido no pertenece a este tenant" };
2968
3881
  }
2969
- const fotoQuery = await db.collection("marketing_fotos").where("tenantId", "==", tenantId).where("id", "==", fotoId).limit(1).get();
3882
+ const fotoQuery = await db.collection("tenants").doc(tenantId).collection("marketing_fotos").where("id", "==", fotoId).limit(1).get();
2970
3883
  if (fotoQuery.empty) {
2971
3884
  return { ok: false, error: `Foto ${fotoId} no encontrada` };
2972
3885
  }
@@ -2998,7 +3911,7 @@ async function photoAssigner(input) {
2998
3911
  if (match) {
2999
3912
  const semanaNum = parseInt(match[1], 10);
3000
3913
  const slotIdx = parseInt(match[2], 10);
3001
- const calQuery = await db.collection("marketing_calendario").where("tenantId", "==", tenantId).where("brandId", "==", brandId).limit(12).get();
3914
+ const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).limit(12).get();
3002
3915
  for (const calDoc of calQuery.docs) {
3003
3916
  const cal = calDoc.data();
3004
3917
  const semanas = cal.semanas ?? [];
@@ -3055,101 +3968,87 @@ async function photoAssigner(input) {
3055
3968
  formato
3056
3969
  };
3057
3970
  }
3058
- async function resolveLastImportId(db, tenantId, brandId) {
3059
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3060
- const config = configSnap.data();
3061
- const brands = config?.brands ?? {};
3062
- const brand = brands[brandId];
3063
- const canalId = brand?.canalId;
3064
- if (!canalId) return null;
3065
- const canalSnap = await db.collection("canales_venta").doc(canalId).get();
3066
- const canal = canalSnap.data();
3067
- const app = canal?.app ?? {};
3068
- return app.lastImportId ?? null;
3971
+ function findPageByHeuristic(pages, pattern) {
3972
+ return pages.find((p) => pattern.test(p.title || "")) || pages.find((p) => pattern.test(p.handle || "")) || null;
3069
3973
  }
3974
+ var ABOUT_PATTERN = /about|nosotros|sobre|quien|historia|story|filosof|esencia/i;
3975
+ var FAQ_PATTERN = /faq|preguntas|frecuent|frequent|questions|ayuda|soporte/i;
3070
3976
  async function brandBriefBuilder(input) {
3071
3977
  const { db, tenantId, brandId } = input;
3072
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3978
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
3073
3979
  if (!configSnap.exists) {
3074
- return { ok: false, error: "No hay marketing_config" };
3075
- }
3076
- const config = configSnap.data();
3077
- const brands = config.brands ?? {};
3078
- const brand = brands[brandId];
3079
- if (!brand) {
3080
3980
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
3081
3981
  }
3082
- const [lastImportId, tenantSnap] = await Promise.all([
3083
- resolveLastImportId(db, tenantId, brandId),
3982
+ const brand = configSnap.data();
3983
+ const [snapshotMetaSnap, productsSnap, collectionsSnap, pagesSnap, siteMetaSnap, tenantSnap] = await Promise.all([
3984
+ db.doc(`tenants/${tenantId}/marketing_snapshots/${brandId}`).get().catch(() => null),
3985
+ db.collection(`tenants/${tenantId}/marketing_snapshots/${brandId}/products`).get().catch(() => null),
3986
+ db.collection(`tenants/${tenantId}/marketing_snapshots/${brandId}/collections`).get().catch(() => null),
3987
+ db.collection(`tenants/${tenantId}/marketing_snapshots/${brandId}/pages`).get().catch(() => null),
3988
+ db.doc(`tenants/${tenantId}/marketing_site_meta/${brandId}`).get().catch(() => null),
3084
3989
  db.collection("tenants").doc(tenantId).get()
3085
3990
  ]);
3086
3991
  const tenantDoc = tenantSnap.data();
3087
- let shopProducts = [];
3088
- let shopCollections = [];
3089
- let shopOrders = null;
3090
- let shopInfo = null;
3091
- if (lastImportId) {
3092
- const basePath = `tenants/${tenantId}/shopify_imports/${lastImportId}/data`;
3093
- const [prodSnap, colSnap, ordSnap, infoSnap] = await Promise.all([
3094
- db.doc(`${basePath}/products`).get().catch(() => null),
3095
- db.doc(`${basePath}/collections`).get().catch(() => null),
3096
- db.doc(`${basePath}/orders_summary`).get().catch(() => null),
3097
- db.doc(`${basePath}/shop_info`).get().catch(() => null)
3098
- ]);
3099
- const prodDoc = prodSnap?.exists ? prodSnap.data() : null;
3100
- const colDoc = colSnap?.exists ? colSnap.data() : null;
3101
- const ordDoc = ordSnap?.exists ? ordSnap.data() : null;
3102
- const infoDoc = infoSnap?.exists ? infoSnap.data() : null;
3103
- shopProducts = (prodDoc?.items ?? []).map((p) => ({
3992
+ const snapshotMeta = snapshotMetaSnap?.exists ? snapshotMetaSnap.data() : null;
3993
+ const lastImportId = snapshotMeta?.lastImportId || null;
3994
+ const shopInfo = snapshotMeta?.shopInfo || null;
3995
+ const ordersSummary = snapshotMeta?.ordersSummary || null;
3996
+ const shopOrders = ordersSummary ? {
3997
+ totalOrders: ordersSummary.totalOrders,
3998
+ averageOrderValue: ordersSummary.avgOrderValue,
3999
+ ordersPerDay: ordersSummary.ordersPerDay,
4000
+ totalRevenue: ordersSummary.totalRevenue,
4001
+ topSkus: ordersSummary.topSkus,
4002
+ dateRange: ordersSummary.dateRange
4003
+ } : null;
4004
+ const shopProducts = (productsSnap?.docs || []).map((doc) => {
4005
+ const p = doc.data();
4006
+ const firstVariant = p.variants?.[0];
4007
+ const firstVariantPrice = firstVariant?.price?.amount;
4008
+ return {
3104
4009
  title: p.title,
3105
- product_type: p.product_type,
3106
- tags: p.tags,
3107
- price_min: p.price_min ?? p.variants?.[0]?.price,
3108
- price_max: p.price_max,
3109
- status: p.status
3110
- }));
3111
- shopCollections = (colDoc?.items ?? []).map((c) => ({
3112
- id: c.id,
4010
+ product_type: p.productType,
4011
+ tags: Array.isArray(p.tags) ? p.tags.join(", ") : p.tags,
4012
+ price_min: p.price?.amount ?? firstVariantPrice ?? null,
4013
+ price_max: null,
4014
+ // canonical no tiene price_max separado, derivable de variants si necesita
4015
+ status: p.available !== false ? "active" : "draft"
4016
+ };
4017
+ });
4018
+ const shopCollections = (collectionsSnap?.docs || []).map((doc) => {
4019
+ const c = doc.data();
4020
+ return {
4021
+ id: c.platformId,
3113
4022
  title: c.title,
3114
4023
  handle: c.handle,
3115
- products_count: c.products_count,
3116
- collectionType: c.collectionType
3117
- }));
3118
- shopOrders = ordDoc ? {
3119
- totalOrders: ordDoc.totalOrders,
3120
- averageOrderValue: ordDoc.averageOrderValue,
3121
- topProducts: ordDoc.topProducts,
3122
- repeatRate: ordDoc.repeatRate,
3123
- currency: ordDoc.currency
3124
- } : null;
3125
- shopInfo = infoDoc ? {
3126
- name: infoDoc.name,
3127
- domain: infoDoc.domain,
3128
- myshopifyDomain: infoDoc.myshopifyDomain,
3129
- country: infoDoc.country,
3130
- currency: infoDoc.currency,
3131
- timezone: infoDoc.timezone,
3132
- plan: infoDoc.plan_name ?? infoDoc.plan,
3133
- languages: infoDoc.languages,
3134
- shippingZones: infoDoc.shippingZones
3135
- } : null;
3136
- }
3137
- const siteSnap = await db.collection("shopify_site_content").doc(`${tenantId}_${brandId}`).get().catch(() => null);
3138
- const siteContentDoc = siteSnap?.exists ? siteSnap.data() : null;
3139
- const sitePages = siteContentDoc?.pages || {};
3140
- const home = sitePages["/"] || {};
3141
- const aboutEn = sitePages["/pages/about"] || {};
3142
- const aboutEs = sitePages["/pages/nosotros"] || {};
3143
- const faqEn = sitePages["/pages/faq"] || {};
3144
- const faqEs = sitePages["/pages/preguntas-frecuentes"] || {};
3145
- const sitePayload = siteContentDoc ? {
3146
- homeTitle: home.title || null,
3147
- homeMeta: home.metaDescription || null,
3148
- homeH1: home.h1 || null,
3149
- homeOgDescription: home.ogDescription || null,
3150
- aboutFirstParagraph: aboutEn.firstParagraph || aboutEs.firstParagraph || null,
3151
- aboutH1: aboutEn.h1 || aboutEs.h1 || null,
3152
- faqQuestions: faqEn.h2s || faqEs.h2s || null
4024
+ products_count: null,
4025
+ // adapter v2 no calcula products_count por collection todavia
4026
+ collectionType: "canonical"
4027
+ };
4028
+ });
4029
+ const siteMetaDoc = siteMetaSnap?.exists ? siteMetaSnap.data() : null;
4030
+ const homepage = siteMetaDoc?.homepage || null;
4031
+ const extraPagesMeta = siteMetaDoc?.extraPages || {};
4032
+ const allPagesCanonical = (pagesSnap?.docs || []).map((doc) => {
4033
+ const p = doc.data();
4034
+ return {
4035
+ handle: p.handle || "",
4036
+ title: p.title || "",
4037
+ firstParagraph: p.firstParagraph || null,
4038
+ h1: p.extractedHeadings?.h1 || null,
4039
+ h2s: p.extractedHeadings?.h2s || []
4040
+ };
4041
+ });
4042
+ const aboutPage = findPageByHeuristic(allPagesCanonical, ABOUT_PATTERN);
4043
+ const faqPage = findPageByHeuristic(allPagesCanonical, FAQ_PATTERN);
4044
+ const sitePayload = siteMetaDoc || homepage ? {
4045
+ homeTitle: homepage?.title || null,
4046
+ homeMeta: homepage?.metaDescription || null,
4047
+ homeH1: homepage?.h1 || null,
4048
+ homeOgDescription: homepage?.ogDescription || null,
4049
+ aboutFirstParagraph: aboutPage?.firstParagraph || null,
4050
+ aboutH1: aboutPage?.h1 || null,
4051
+ faqQuestions: faqPage?.h2s?.length ? faqPage.h2s : null
3153
4052
  } : null;
3154
4053
  const seo = brand.seoSnapshot;
3155
4054
  const gbpPerfiles = brand.gbpPerfiles ?? [];
@@ -3182,14 +4081,20 @@ async function brandBriefBuilder(input) {
3182
4081
  }
3183
4082
  const topTags = Object.entries(tagCount).sort(([, a], [, b]) => b - a).slice(0, 20).map(([tag, count]) => ({ tag, count }));
3184
4083
  const instruccionBase = `Genera un brandBrief completo para "${brand.nombre ?? brandId}". Pre-llena TODOS los campos bas\xE1ndote en los datos a continuaci\xF3n. El 80% debe ser inferido autom\xE1ticamente. Marca lo que el tenant debe validar. Guarda con save_brand_brief.`;
3185
- const instruccionSiteContent = sitePayload ? " Tienes site_content REAL scrapeado del sitio en datosDisponibles.sitePayload. Usa: homeMeta y homeOgDescription para descripcionCorta; aboutFirstParagraph para propuestaDeValor (texto real, no inventes); faqQuestions para inferir mensajesClave y dudas frecuentes; aboutH1 para inferir tono y posicionamiento. Si algun campo es null, infierelo de Shopify + GBP." : ' No hay site_content disponible (sitePayload es null). Recomienda al tenant correr "Sincronizar Shopify" con modo full para mejor calidad del brief.';
4084
+ const instruccionSiteContent = sitePayload ? " Tienes site_content REAL scrapeado del sitio en datosDisponibles.sitePayload. Usa: homeMeta y homeOgDescription para descripcionCorta; aboutFirstParagraph para propuestaDeValor (texto real, no inventes); faqQuestions para inferir mensajesClave y dudas frecuentes; aboutH1 para inferir tono y posicionamiento. Si algun campo es null, intenta identificar la pagina correcta en datosDisponibles.availablePages (titles + handles) y usa ese contenido \u2014 la heuristica regex puede haber fallado para un tenant con nombres creativos. Si igual no aparece, infierelo de Shopify + GBP." : ' No hay site_content disponible (sitePayload es null). Recomienda al tenant correr "Sincronizar Shopify" con modo full para mejor calidad del brief.';
4085
+ const availablePages = allPagesCanonical.map((p) => ({
4086
+ handle: p.handle,
4087
+ title: p.title,
4088
+ h1: p.h1
4089
+ }));
3186
4090
  const payload = {
3187
4091
  instruccion: instruccionBase + instruccionSiteContent,
3188
- _siteContentVersion: siteContentDoc?.importId || null,
4092
+ _lastImportId: lastImportId,
3189
4093
  _schema: BRAND_BRIEF_SCHEMA_HINT,
3190
4094
  datosDisponibles: {
3191
4095
  shopInfo,
3192
4096
  sitePayload,
4097
+ availablePages,
3193
4098
  precioStats,
3194
4099
  productTypes: typeCount,
3195
4100
  topTags,
@@ -3250,11 +4155,51 @@ var BRAND_BRIEF_SCHEMA_HINT = {
3250
4155
  escenas: [{ id: string, nombre: string, promptHint: string }]
3251
4156
  }`
3252
4157
  };
3253
- async function resolveLastImportId2(db, tenantId, brandId) {
3254
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3255
- const config = configSnap.data();
3256
- const brands = config?.brands ?? {};
3257
- const brand = brands[brandId];
4158
+ var ParamsSchema4 = import_zod32.z.object({
4159
+ tenantId: import_zod32.z.string().min(1),
4160
+ brandId: import_zod32.z.string().min(1)
4161
+ });
4162
+ var OutputSchema4 = import_zod32.z.discriminatedUnion("ok", [
4163
+ import_zod32.z.object({
4164
+ ok: import_zod32.z.literal(true),
4165
+ /** Payload con instrucción + datos del negocio para que Claude genere el brief. */
4166
+ payload: import_zod32.z.record(import_zod32.z.string(), import_zod32.z.unknown())
4167
+ }),
4168
+ import_zod32.z.object({
4169
+ ok: import_zod32.z.literal(false),
4170
+ error: import_zod32.z.string()
4171
+ })
4172
+ ]);
4173
+ var rawContract4 = {
4174
+ name: "generate_brand_brief",
4175
+ description: "Prepara los datos del negocio (Shopify, SEO, GBP, site_content scrape, brand config, ubicaciones del tenant) para que el sistema genere un Brand Brief pre-llenado. NO escribe el brief \u2014 s\xF3lo arma el payload.",
4176
+ paramsSchema: ParamsSchema4,
4177
+ outputSchema: OutputSchema4,
4178
+ // Lectura pura, sin side effects de escritura ni publicación.
4179
+ requiresConfirmation: false,
4180
+ destructive: false,
4181
+ affectsPublication: false,
4182
+ affectsExternal: false,
4183
+ martinSummaryTemplate: (_input, output, locale) => {
4184
+ if (!output.ok) {
4185
+ if (locale === "en") return `I couldn't gather the data: ${output.error}`;
4186
+ return `No pude reunir los datos: ${output.error}`;
4187
+ }
4188
+ if (locale === "en") return `I gathered the data for the brief.`;
4189
+ return `Reun\xED los datos para el brief.`;
4190
+ },
4191
+ auditAction: "marketing.brand_brief.preparar",
4192
+ // No extractTargetPath/extractChanges — no es writer.
4193
+ quotasConsumed: [],
4194
+ requiredRoles: ["admin", "encargado"],
4195
+ sideEffects: ["reads_firestore"]
4196
+ };
4197
+ var brandBriefBuilderContract = MartinContractSchema.parse(
4198
+ rawContract4
4199
+ );
4200
+ async function resolveLastImportId(db, tenantId, brandId) {
4201
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4202
+ const brand = configSnap.data();
3258
4203
  const canalId = brand?.canalId;
3259
4204
  if (!canalId) return null;
3260
4205
  const canalSnap = await db.collection("canales_venta").doc(canalId).get();
@@ -3264,16 +4209,11 @@ async function resolveLastImportId2(db, tenantId, brandId) {
3264
4209
  }
3265
4210
  async function marketingPlanBuilder(input) {
3266
4211
  const { db, tenantId, brandId } = input;
3267
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
4212
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
3268
4213
  if (!configSnap.exists) {
3269
- return { ok: false, error: "No hay marketing_config" };
3270
- }
3271
- const config = configSnap.data();
3272
- const brands = config.brands ?? {};
3273
- const brand = brands[brandId];
3274
- if (!brand) {
3275
4214
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
3276
4215
  }
4216
+ const brand = configSnap.data();
3277
4217
  if (!brand.seoSnapshot) {
3278
4218
  return {
3279
4219
  ok: false,
@@ -3282,9 +4222,9 @@ async function marketingPlanBuilder(input) {
3282
4222
  }
3283
4223
  const productosQ = await db.collection("productos").where("tenantId", "==", tenantId).limit(100).get();
3284
4224
  const productos = productosQ.docs.map((d) => d.data());
3285
- const histQ = await db.collection("marketing_contenido").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("estado", "==", ESTADO_CONTENIDO.PUBLICADO).limit(20).get();
4225
+ const histQ = await db.collection("tenants").doc(tenantId).collection("marketing_contenido").where("brandId", "==", brandId).where("estado", "==", ESTADO_CONTENIDO.PUBLICADO).limit(20).get();
3286
4226
  const historial = histQ.docs.map((d) => d.data());
3287
- const lastImportId = await resolveLastImportId2(db, tenantId, brandId);
4227
+ const lastImportId = await resolveLastImportId(db, tenantId, brandId);
3288
4228
  let colecciones = [];
3289
4229
  let shopifyBlogs = [];
3290
4230
  if (lastImportId) {
@@ -3459,13 +4399,11 @@ async function contenidoWriter(input) {
3459
4399
  calendarioItemRef,
3460
4400
  linkPipeline
3461
4401
  } = input;
3462
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3463
- const config = configSnap.data();
3464
- const brands = config?.brands ?? {};
3465
- const brand = brands[brandId];
4402
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4403
+ const brand = configSnap.exists ? configSnap.data() : void 0;
3466
4404
  const frecuencia = brand?.frecuencia ?? {};
3467
4405
  const estadosQueOcupanSlot = ["aprobado", "publicado"];
3468
- const contenidoQ = await db.collection("marketing_contenido").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("plataforma", "==", plataforma).limit(100).get();
4406
+ const contenidoQ = await db.collection("tenants").doc(tenantId).collection("marketing_contenido").where("brandId", "==", brandId).where("plataforma", "==", plataforma).limit(100).get();
3469
4407
  const contenidoEstaSemana = contenidoQ.docs.map((d2) => ({ id: d2.id, ...d2.data() }));
3470
4408
  const now = /* @__PURE__ */ new Date();
3471
4409
  const startOfWeek = new Date(now);
@@ -3498,7 +4436,7 @@ async function contenidoWriter(input) {
3498
4436
  (c) => c.calendarioItemRef === calendarioItemRef && c.estado !== "descartado"
3499
4437
  );
3500
4438
  for (const existente of existentes) {
3501
- await db.collection("marketing_contenido").doc(existente.id).update({
4439
+ await db.doc(`tenants/${tenantId}/marketing_contenido/${existente.id}`).update({
3502
4440
  estado: "descartado",
3503
4441
  rechazadoMotivo: "Reemplazado por contenido nuevo para el mismo slot",
3504
4442
  rechazadoAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp()
@@ -3542,6 +4480,11 @@ async function contenidoWriter(input) {
3542
4480
  }
3543
4481
  }
3544
4482
  const id = buildGenId();
4483
+ if (!languageCode) {
4484
+ throw new Error(
4485
+ `contenidoWriter requiere languageCode expl\xEDcito. Resolver desde brand.idiomas.primario o tenant.idiomaPrincipal antes de invocar.`
4486
+ );
4487
+ }
3545
4488
  const contenido = buildContenido({
3546
4489
  id,
3547
4490
  tenantId,
@@ -3549,7 +4492,7 @@ async function contenidoWriter(input) {
3549
4492
  plataforma,
3550
4493
  tipo: tipo ?? "post",
3551
4494
  keyword: keyword ?? null,
3552
- languageCode: languageCode ?? "es",
4495
+ languageCode,
3553
4496
  estado: ESTADO_CONTENIDO.PENDIENTE_APROBACION,
3554
4497
  fotoId: fotoId ?? null,
3555
4498
  mediaUrl: null,
@@ -3559,14 +4502,14 @@ async function contenidoWriter(input) {
3559
4502
  creadoPorId: "mcp-cowork",
3560
4503
  origen: "ai_assisted"
3561
4504
  });
3562
- const contenidoRef = db.collection("marketing_contenido").doc(id);
4505
+ const contenidoRef = db.doc(`tenants/${tenantId}/marketing_contenido/${id}`);
3563
4506
  let slotResolved = null;
3564
4507
  if (calendarioItemRef) {
3565
4508
  const match = calendarioItemRef.match(/^semana:(\d+):slot:(\d+)$/);
3566
4509
  if (match) {
3567
4510
  const semanaNum = parseInt(match[1], 10);
3568
4511
  const slotIdx = parseInt(match[2], 10);
3569
- const calQ = await db.collection("marketing_calendario").where("tenantId", "==", tenantId).where("brandId", "==", brandId).limit(12).get();
4512
+ const calQ = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).limit(12).get();
3570
4513
  for (const calDoc of calQ.docs) {
3571
4514
  const cal = calDoc.data();
3572
4515
  const semanas = cal.semanas ?? [];
@@ -3715,14 +4658,12 @@ async function weeklyContentBuilder(input) {
3715
4658
  const now = /* @__PURE__ */ new Date();
3716
4659
  const targetMes = now.toISOString().slice(0, 7);
3717
4660
  const targetSemana = semana ?? Math.ceil(now.getDate() / 7);
3718
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
4661
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
3719
4662
  if (!configSnap.exists) {
3720
- return { ok: false, error: "No hay marketing_config" };
4663
+ return { ok: false, error: `Brand "${brandId}" no encontrada` };
3721
4664
  }
3722
- const config = configSnap.data();
3723
- const brands = config.brands ?? {};
3724
- const brand = brands[brandId];
3725
- const calQuery = await db.collection("marketing_calendario").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("mes", "==", targetMes).limit(1).get();
4665
+ const brand = configSnap.data();
4666
+ const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", targetMes).limit(1).get();
3726
4667
  let calendario = calQuery.empty ? null : { id: calQuery.docs[0].id, ...calQuery.docs[0].data() };
3727
4668
  if (!calendario) {
3728
4669
  const calId = `${tenantId}_${brandId}_${targetMes}`;
@@ -3737,14 +4678,14 @@ async function weeklyContentBuilder(input) {
3737
4678
  creadoPorId: "mcp-cowork",
3738
4679
  updatedAt: null
3739
4680
  };
3740
- await db.collection("marketing_calendario").doc(calId).set(calDoc);
4681
+ await db.doc(`tenants/${tenantId}/marketing_calendario/${calId}`).set(calDoc);
3741
4682
  calendario = { ...calDoc, semanas };
3742
4683
  }
3743
4684
  const semanasArr = calendario.semanas ?? [];
3744
4685
  const semanaData = semanasArr[targetSemana - 1] ?? null;
3745
4686
  const slotsExistentes = semanaData?.items ?? [];
3746
4687
  if (targetModo === "planificar") {
3747
- const histQ2 = await db.collection("marketing_contenido").where("tenantId", "==", tenantId).where("brandId", "==", brandId).limit(20).get();
4688
+ const histQ2 = await db.collection("tenants").doc(tenantId).collection("marketing_contenido").where("brandId", "==", brandId).limit(20).get();
3748
4689
  const historial2 = histQ2.docs.map((d) => d.data());
3749
4690
  return {
3750
4691
  ok: true,
@@ -3802,9 +4743,9 @@ async function weeklyContentBuilder(input) {
3802
4743
  }
3803
4744
  };
3804
4745
  }
3805
- const fotosQ = await db.collection("marketing_fotos").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("estado", "==", ESTADO_FOTO.EDITADA).limit(20).get();
4746
+ const fotosQ = await db.collection("tenants").doc(tenantId).collection("marketing_fotos").where("brandId", "==", brandId).where("estado", "==", ESTADO_FOTO.EDITADA).limit(20).get();
3806
4747
  const fotosDisponibles = fotosQ.docs.map((d) => d.data());
3807
- const histQ = await db.collection("marketing_contenido").where("tenantId", "==", tenantId).where("brandId", "==", brandId).limit(20).get();
4748
+ const histQ = await db.collection("tenants").doc(tenantId).collection("marketing_contenido").where("brandId", "==", brandId).limit(20).get();
3808
4749
  const historial = histQ.docs.map((d) => d.data());
3809
4750
  const hasBlogSlots = slotsParaGenerar.some((s) => s.plataforma === "shopify_blog");
3810
4751
  let blogJIT = null;
@@ -3812,7 +4753,7 @@ async function weeklyContentBuilder(input) {
3812
4753
  const blogStrategy = brand.blogStrategy;
3813
4754
  const blogs = blogStrategy?.blogs ?? [];
3814
4755
  const idiomas = brand.idiomas;
3815
- const articulosQ = await db.collection("marketing_contenido").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("plataforma", "==", "shopify_blog").where("estado", "==", ESTADO_CONTENIDO.PUBLICADO).limit(20).get();
4756
+ const articulosQ = await db.collection("tenants").doc(tenantId).collection("marketing_contenido").where("brandId", "==", brandId).where("plataforma", "==", "shopify_blog").where("estado", "==", ESTADO_CONTENIDO.PUBLICADO).limit(20).get();
3816
4757
  const articulosExistentes = articulosQ.docs.map(
3817
4758
  (d) => d.data()
3818
4759
  );
@@ -3848,7 +4789,15 @@ async function weeklyContentBuilder(input) {
3848
4789
  userId: blogStrategy?.defaultAuthorId ?? null,
3849
4790
  name: blogStrategy?.defaultAuthorName ?? null
3850
4791
  },
3851
- languageCode: idiomas?.primario ?? "es"
4792
+ languageCode: (() => {
4793
+ const lc = idiomas?.primario;
4794
+ if (!lc) {
4795
+ throw new Error(
4796
+ `weeklyContentBuilder requiere brand.idiomas.primario. Configurar antes de generar weekly content.`
4797
+ );
4798
+ }
4799
+ return lc;
4800
+ })()
3852
4801
  });
3853
4802
  }
3854
4803
  blogJIT = {
@@ -3981,10 +4930,8 @@ async function contentFinder(input) {
3981
4930
  const textThreshold = deps.thresholds?.text ?? DEFAULT_TEXT_THRESHOLD;
3982
4931
  const imageThreshold = deps.thresholds?.image ?? DEFAULT_IMAGE_THRESHOLD;
3983
4932
  const conflictThreshold = deps.thresholds?.conflictSimilarity ?? DEFAULT_CONFLICT_THRESHOLD;
3984
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3985
- const config = configSnap.exists ? configSnap.data() : null;
3986
- const brands = config?.brands ?? {};
3987
- const brand = brands[brandId] ?? null;
4933
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4934
+ const brand = configSnap.exists ? configSnap.data() : null;
3988
4935
  const brandBrief = brand?.brandBrief ?? null;
3989
4936
  const visualRules = brandBrief?.visualRules ?? {};
3990
4937
  const plan = brand?.plan ?? null;
@@ -4041,22 +4988,22 @@ async function contentFinder(input) {
4041
4988
  const productsLimit = include.products ? collFilter ? limit.products * 4 : limit.products * 2 : 0;
4042
4989
  const [productsRaw, collectionsRaw, articlesRaw, pagesRaw] = await Promise.all([
4043
4990
  searchCollection(
4044
- "shopify_products",
4991
+ "products",
4045
4992
  include.products && limit.products > 0,
4046
4993
  productsLimit
4047
4994
  ),
4048
4995
  searchCollection(
4049
- "shopify_collections",
4996
+ "collections",
4050
4997
  include.collections && limit.collections > 0,
4051
4998
  limit.collections * 2
4052
4999
  ),
4053
5000
  searchCollection(
4054
- "shopify_articles",
5001
+ "articles",
4055
5002
  include.articles && limit.articles > 0,
4056
5003
  limit.articles * 2
4057
5004
  ),
4058
5005
  searchCollection(
4059
- "shopify_pages",
5006
+ "pages",
4060
5007
  include.pages && limit.pages > 0,
4061
5008
  limit.pages * 2
4062
5009
  )
@@ -4161,16 +5108,11 @@ async function slotAssetFinder(input) {
4161
5108
  const limit = input.limit ?? 5;
4162
5109
  const photoThreshold = deps.thresholds?.photos ?? DEFAULT_PHOTO_THRESHOLD;
4163
5110
  const shopifyThreshold = deps.thresholds?.shopify ?? DEFAULT_SHOPIFY_THRESHOLD;
4164
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
5111
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4165
5112
  if (!configSnap.exists) {
4166
- return { ok: false, error: `Brand "${brandId}" no encontrada (no marketing_config)` };
4167
- }
4168
- const config = configSnap.data();
4169
- const brands = config.brands ?? {};
4170
- const brand = brands[brandId] ?? null;
4171
- if (!brand) {
4172
5113
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
4173
5114
  }
5115
+ const brand = configSnap.data();
4174
5116
  const brandBrief = brand.brandBrief ?? null;
4175
5117
  const visualRules = brandBrief?.visualRules ?? {};
4176
5118
  const plan = brand.plan ?? null;
@@ -4196,7 +5138,7 @@ async function slotAssetFinder(input) {
4196
5138
  try {
4197
5139
  const fetchLimit = bloqueoProducto && temporada?.coleccion?.handle ? limit * 4 : limit;
4198
5140
  const nearest = await deps.findNearestInCollection({
4199
- collection: "shopify_products",
5141
+ collection: "products",
4200
5142
  tenantId,
4201
5143
  brandId,
4202
5144
  queryEmbedding: queryEmbedding ?? void 0,
@@ -4298,16 +5240,11 @@ function cosineSimilarity(a, b) {
4298
5240
  async function canvaTemplateSelector(input) {
4299
5241
  const { db, tenantId, brandId, plataforma, tipoContenido, keyword, deps } = input;
4300
5242
  const threshold = deps.threshold ?? DEFAULT_SIMILARITY_THRESHOLD;
4301
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
5243
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4302
5244
  if (!configSnap.exists) {
4303
5245
  return { plantillaId: null, motivo: "brand_no_encontrada" };
4304
5246
  }
4305
- const config = configSnap.data();
4306
- const brands = config.brands ?? {};
4307
- const brand = brands[brandId] ?? null;
4308
- if (!brand) {
4309
- return { plantillaId: null, motivo: "brand_no_encontrada" };
4310
- }
5247
+ const brand = configSnap.data();
4311
5248
  const canva = brand.canva ?? {};
4312
5249
  if (!canva.connected || !Array.isArray(canva.templates) || canva.templates.length === 0) {
4313
5250
  return {
@@ -4465,7 +5402,7 @@ var ERROR_MESSAGES = {
4465
5402
  en: () => `External infrastructure failure. You can retry ONCE. If it persists, report the error and try another photo.`
4466
5403
  }
4467
5404
  };
4468
- function instruccionesParaError(code, details, lang = "es") {
5405
+ function instruccionesParaError(code, details, lang) {
4469
5406
  const messages = ERROR_MESSAGES[code];
4470
5407
  if (!messages) {
4471
5408
  return lang === "en" ? `Unknown error. Report code=${code} and details to the tech team.` : `Error desconocido. Reporta el code=${code} y detalles al equipo tecnico.`;
@@ -4474,16 +5411,14 @@ function instruccionesParaError(code, details, lang = "es") {
4474
5411
  }
4475
5412
  async function photoDirectorPlan(input) {
4476
5413
  const { db, tenantId, fotoId, deps } = input;
4477
- const fotoQ = await db.collection("marketing_fotos").where("tenantId", "==", tenantId).where("id", "==", fotoId).limit(1).get();
5414
+ const fotoQ = await db.collection("tenants").doc(tenantId).collection("marketing_fotos").where("id", "==", fotoId).limit(1).get();
4478
5415
  if (fotoQ.empty) {
4479
5416
  return { ok: false, code: "FOTO_NOT_FOUND", error: `Foto ${fotoId} no encontrada` };
4480
5417
  }
4481
5418
  const foto = fotoQ.docs[0].data();
4482
5419
  const brandId = foto.brandId;
4483
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
4484
- const config = configSnap.exists ? configSnap.data() : null;
4485
- const brands = config?.brands ?? {};
4486
- const brand = brands[brandId] ?? null;
5420
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
5421
+ const brand = configSnap.exists ? configSnap.data() : null;
4487
5422
  const brandBrief = (brand?.brandBrief ?? null) || {};
4488
5423
  const visualRules = brandBrief.visualRules ?? {};
4489
5424
  const catalogoVisual = brandBrief.catalogoVisual;
@@ -4910,15 +5845,12 @@ Campos comunes de save_generated_content:
4910
5845
  tipo: string (del slot)
4911
5846
  `;
4912
5847
  async function buildTenantContext(db, tenantId, brandId) {
4913
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
5848
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4914
5849
  if (!configSnap.exists) return "";
4915
- const config = configSnap.data();
4916
- const brands = config.brands ?? {};
4917
- const brand = brands[brandId];
4918
- if (!brand) return "";
5850
+ const brand = configSnap.data();
4919
5851
  const [fotosQ, contenidoQ, productosQ] = await Promise.all([
4920
- db.collection("marketing_fotos").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("estado", "==", ESTADO_FOTO.EDITADA).limit(10).get(),
4921
- db.collection("marketing_contenido").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("estado", "==", ESTADO_CONTENIDO.PUBLICADO).limit(10).get(),
5852
+ db.collection("tenants").doc(tenantId).collection("marketing_fotos").where("brandId", "==", brandId).where("estado", "==", ESTADO_FOTO.EDITADA).limit(10).get(),
5853
+ db.collection("tenants").doc(tenantId).collection("marketing_contenido").where("brandId", "==", brandId).where("estado", "==", ESTADO_CONTENIDO.PUBLICADO).limit(10).get(),
4922
5854
  db.collection("productos").where("tenantId", "==", tenantId).limit(50).get()
4923
5855
  ]);
4924
5856
  const fotosEditadas = fotosQ.docs.map((d) => d.data());
@@ -4943,7 +5875,12 @@ Dominio: ${brand.dominio ?? "sin dominio"}
4943
5875
  ctx += `Tono: ${plan?.tonoMarca ?? "profesional y cercano"}
4944
5876
  `;
4945
5877
  if (idiomas) {
4946
- ctx += `Idioma principal: ${idiomas.primario ?? "es"}
5878
+ if (!idiomas.primario) {
5879
+ throw new Error(
5880
+ `systemPromptBuilder requiere idiomas.primario. Brand sin idioma configurado \u2014 completar marketing_config.brands[bId].idiomas.`
5881
+ );
5882
+ }
5883
+ ctx += `Idioma principal: ${idiomas.primario}
4947
5884
  `;
4948
5885
  if (idiomas.distribucion) {
4949
5886
  ctx += `Distribucion de idiomas: ${JSON.stringify(idiomas.distribucion)}
@@ -5122,10 +6059,8 @@ async function buildSystemPrompt(input) {
5122
6059
  const p2 = PARTE_2_REGLAS.replace("{{SHAPES_BLOCK}}", shapesBlock);
5123
6060
  return p1 + p2;
5124
6061
  }
5125
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
5126
- const config = configSnap.exists ? configSnap.data() : null;
5127
- const brands = config?.brands ?? {};
5128
- const brand = brands[brandId];
6062
+ const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
6063
+ const brand = configSnap.exists ? configSnap.data() : null;
5129
6064
  let prompt = PARTE_1_IDENTIDAD.replace(
5130
6065
  "{{brand_nombre}}",
5131
6066
  brand?.nombre ?? brandId
@@ -5137,6 +6072,451 @@ async function buildSystemPrompt(input) {
5137
6072
  }
5138
6073
  return prompt;
5139
6074
  }
6075
+ async function writeAuditLog(input) {
6076
+ try {
6077
+ const db = (0, import_firestore3.getFirestore)();
6078
+ const eventId = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6079
+ const entryInput = {
6080
+ schemaVersion: 1,
6081
+ id: eventId,
6082
+ tenantId: input.tenantId,
6083
+ brandId: input.brandId ?? null,
6084
+ actor: input.actor,
6085
+ action: input.action,
6086
+ targetType: input.targetType ?? "",
6087
+ targetPath: input.targetPath ?? "",
6088
+ targetId: input.targetId ?? "",
6089
+ changes: input.changes ?? { before: null, after: null },
6090
+ motivo: input.motivo ?? null,
6091
+ conversacionId: input.conversacionId ?? null,
6092
+ status: input.status ?? "success",
6093
+ errorMessage: input.errorMessage ?? null,
6094
+ durationMs: input.durationMs ?? 0,
6095
+ timestamp: import_firestore3.FieldValue.serverTimestamp()
6096
+ };
6097
+ if (input.origenTecnico !== void 0) {
6098
+ entryInput.origenTecnico = input.origenTecnico;
6099
+ }
6100
+ const entry = buildAuditLogEntry(entryInput);
6101
+ await db.doc(`tenants/${input.tenantId}/audit_log/${eventId}`).set(entry);
6102
+ } catch (err) {
6103
+ console.error("[auditLog] Failed to write:", err, {
6104
+ action: input.action,
6105
+ tenantId: input.tenantId
6106
+ });
6107
+ }
6108
+ }
6109
+ async function getEffectiveLimits(tenantId) {
6110
+ const db = (0, import_firestore4.getFirestore)();
6111
+ const tenantSnap = await db.doc(`tenants/${tenantId}`).get();
6112
+ if (!tenantSnap.exists) {
6113
+ throw new Error(`Tenant ${tenantId} no existe`);
6114
+ }
6115
+ const tenant = tenantSnap.data();
6116
+ const membresiaId = tenant.membresiaId;
6117
+ if (!membresiaId) {
6118
+ throw new Error(`Tenant ${tenantId} no tiene membresiaId`);
6119
+ }
6120
+ const membresiaSnap = await db.doc(`membresias/${membresiaId}`).get();
6121
+ if (!membresiaSnap.exists) {
6122
+ throw new Error(`Membresia ${membresiaId} no existe`);
6123
+ }
6124
+ const membresia = membresiaSnap.data();
6125
+ const membresiaLimites = membresia.limites ?? {};
6126
+ const limits = {
6127
+ weeklyContentsPerMonth: membresiaLimites.weeklyContentsPerMonth ?? null,
6128
+ fotosPorMes: membresiaLimites.fotosPorMes ?? null,
6129
+ auditOptimizationsPerMonth: membresiaLimites.auditOptimizationsPerMonth ?? null,
6130
+ aiCostBudgetUSD: membresiaLimites.aiCostBudgetUSD ?? null,
6131
+ brandsIncluidas: membresiaLimites.brandsIncluidas ?? null,
6132
+ usuarios: membresiaLimites.usuarios ?? null,
6133
+ ubicaciones: membresiaLimites.ubicaciones ?? null,
6134
+ productos: membresiaLimites.productos ?? null,
6135
+ insumos: membresiaLimites.insumos ?? null,
6136
+ ordenesMes: membresiaLimites.ordenesMes ?? null,
6137
+ almacenamientoGB: membresiaLimites.almacenamientoGB ?? null,
6138
+ creditosMes: membresiaLimites.creditosMes ?? null
6139
+ };
6140
+ const addonsActivos = tenant.addonsActivos ?? [];
6141
+ const now = Date.now();
6142
+ const activeAddons = addonsActivos.filter((a) => {
6143
+ if (a.estado !== "active") return false;
6144
+ const finVigencia = a.finVigencia;
6145
+ if (finVigencia && typeof finVigencia.toMillis === "function" && finVigencia.toMillis() < now) {
6146
+ return false;
6147
+ }
6148
+ return true;
6149
+ });
6150
+ if (activeAddons.length === 0) return limits;
6151
+ const addonDocs = await Promise.all(
6152
+ activeAddons.map(
6153
+ (a) => db.doc(`membresia_addons/${a.addonId}`).get()
6154
+ )
6155
+ );
6156
+ for (let i = 0; i < activeAddons.length; i++) {
6157
+ const addonSnap = addonDocs[i];
6158
+ if (!addonSnap.exists) continue;
6159
+ const addon = addonSnap.data();
6160
+ const cantidad = activeAddons[i].cantidad;
6161
+ for (const [key, increment] of Object.entries(addon.incrementa ?? {})) {
6162
+ const k = key;
6163
+ if (limits[k] === null) continue;
6164
+ limits[k] = (limits[k] ?? 0) + increment * cantidad;
6165
+ }
6166
+ }
6167
+ return limits;
6168
+ }
6169
+ async function checkQuota(tenantId, quotaName) {
6170
+ const limits = await getEffectiveLimits(tenantId);
6171
+ const limit = limits[quotaName];
6172
+ if (limit === null) {
6173
+ return { ok: true, limit: null, current: 0, remaining: null };
6174
+ }
6175
+ const current = await getCurrentUsage(tenantId, quotaName);
6176
+ const remaining = Math.max(0, limit - current);
6177
+ const ok = current < limit;
6178
+ return {
6179
+ ok,
6180
+ limit,
6181
+ current,
6182
+ remaining,
6183
+ reason: ok ? void 0 : `Alcanzaste el l\xEDmite de ${quotaName}: ${current}/${limit}`
6184
+ };
6185
+ }
6186
+ async function getCurrentUsage(_tenantId, _quotaName) {
6187
+ return 0;
6188
+ }
6189
+ async function resolveTenantIdioma(tenantId) {
6190
+ const db = (0, import_firestore5.getFirestore)();
6191
+ const tenantSnap = await db.doc(`tenants/${tenantId}`).get();
6192
+ if (!tenantSnap.exists) {
6193
+ throw new Error(`Tenant ${tenantId} no existe`);
6194
+ }
6195
+ const data = tenantSnap.data();
6196
+ const idioma = data.idiomaPrincipal ?? data.idioma;
6197
+ if (!idioma) {
6198
+ throw new Error(
6199
+ `Tenant ${tenantId} sin idiomaPrincipal configurado. Completar tenants/${tenantId}.idiomaPrincipal antes de invocar helpers que generen contenido.`
6200
+ );
6201
+ }
6202
+ return idioma;
6203
+ }
6204
+ var MESSAGES = {
6205
+ es: {
6206
+ generic: "Tuve un problema. Intent\xE9moslo de nuevo en un momento.",
6207
+ quota_exceeded: "Alcanzaste el l\xEDmite mensual.",
6208
+ not_found: "No lo encontr\xE9. \xBFPodr\xEDas verificar el dato?",
6209
+ permission: "No tengo permiso para hacer eso desde tu cuenta.",
6210
+ timeout: "Esto est\xE1 tardando m\xE1s de lo normal. \xBFLo intento de nuevo?"
6211
+ },
6212
+ en: {
6213
+ generic: "I had a problem. Let's try again in a moment.",
6214
+ quota_exceeded: "You reached the monthly limit.",
6215
+ not_found: "I couldn't find it. Could you verify the data?",
6216
+ permission: "I don't have permission to do that from your account.",
6217
+ timeout: "This is taking longer than usual. Should I try again?"
6218
+ }
6219
+ };
6220
+ function martinSafeError(err, locale) {
6221
+ if (!(locale in MESSAGES)) {
6222
+ throw new Error(
6223
+ `Locale '${locale}' no soportado en martinSafeError. Locales activos: ${Object.keys(MESSAGES).join(", ")}. Agregar traducciones antes de usar.`
6224
+ );
6225
+ }
6226
+ const msgs = MESSAGES[locale];
6227
+ const e = err;
6228
+ const message = e?.message ?? "";
6229
+ const code = e?.code ?? "";
6230
+ if (/quota|límite|limit/i.test(message)) return msgs.quota_exceeded;
6231
+ if (/not.found|no.encontrado|no existe/i.test(message)) return msgs.not_found;
6232
+ if (/permission|permiso/i.test(message)) return msgs.permission;
6233
+ if (code === "deadline-exceeded" || /timeout/i.test(message)) return msgs.timeout;
6234
+ return msgs.generic;
6235
+ }
6236
+ var MESSAGES2 = {
6237
+ es: {
6238
+ denied_role: "No tengo permiso para hacer eso desde tu cuenta. P\xEDdelo al encargado o admin que te autorice.",
6239
+ input_invalido: "Falta algo o est\xE1 mal. \xBFLo intentamos de nuevo?",
6240
+ confirm_default: "\xBFConfirmas?"
6241
+ },
6242
+ en: {
6243
+ denied_role: "I don't have permission to do that from your account. Ask the manager or admin to authorize it.",
6244
+ input_invalido: "Something is missing or off. Could you try again?",
6245
+ confirm_default: "Are you sure?"
6246
+ }
6247
+ };
6248
+ function getWrapperMessage(key, locale) {
6249
+ if (!(locale in MESSAGES2)) {
6250
+ throw new Error(
6251
+ `Locale '${locale}' no soportado en getWrapperMessage. Locales activos: ${Object.keys(MESSAGES2).join(", ")}. Agregar traducciones antes de usar.`
6252
+ );
6253
+ }
6254
+ const msg = MESSAGES2[locale][key];
6255
+ if (!msg) {
6256
+ throw new Error(
6257
+ `Mensaje '${key}' no definido en wrapperMessages para locale '${locale}'.`
6258
+ );
6259
+ }
6260
+ return msg;
6261
+ }
6262
+ function hasRequiredRole(userRol, required) {
6263
+ if (required.includes("*")) return true;
6264
+ return required.includes(userRol);
6265
+ }
6266
+ function wrapWithContract(contract, helper) {
6267
+ return async function wrappedTool(input, ctx) {
6268
+ const startMs = Date.now();
6269
+ const locale = ctx.user.idiomaPreferido;
6270
+ if (!hasRequiredRole(ctx.user.rol, contract.requiredRoles)) {
6271
+ await writeAuditLog({
6272
+ tenantId: ctx.tenantId,
6273
+ brandId: ctx.brandId ?? null,
6274
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6275
+ action: contract.auditAction,
6276
+ motivo: `Acceso denegado \u2014 rol '${ctx.user.rol}' no autorizado`,
6277
+ conversacionId: ctx.conversacionId ?? null,
6278
+ status: "error",
6279
+ errorMessage: `Required: ${contract.requiredRoles.join(",")}, got: ${ctx.user.rol}`,
6280
+ durationMs: Date.now() - startMs
6281
+ });
6282
+ return {
6283
+ text: getWrapperMessage("denied_role", locale),
6284
+ structuredOutput: null,
6285
+ state: "denied_role"
6286
+ };
6287
+ }
6288
+ const parseResult = contract.paramsSchema.safeParse(input);
6289
+ if (!parseResult.success) {
6290
+ await writeAuditLog({
6291
+ tenantId: ctx.tenantId,
6292
+ brandId: ctx.brandId ?? null,
6293
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6294
+ action: contract.auditAction,
6295
+ motivo: "Input inv\xE1lido \u2014 Zod parse failed",
6296
+ conversacionId: ctx.conversacionId ?? null,
6297
+ status: "error",
6298
+ errorMessage: `Input shape inv\xE1lido: ${parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
6299
+ durationMs: Date.now() - startMs
6300
+ });
6301
+ return {
6302
+ text: getWrapperMessage("input_invalido", locale),
6303
+ structuredOutput: null,
6304
+ state: "error"
6305
+ };
6306
+ }
6307
+ const parsedInput = parseResult.data;
6308
+ if (contract.requiresConfirmation && !ctx.confirmationGranted) {
6309
+ const confirmMsg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
6310
+ return {
6311
+ text: confirmMsg,
6312
+ structuredOutput: null,
6313
+ state: "pending_confirmation"
6314
+ };
6315
+ }
6316
+ if (contract.requiresDoubleConfirmation && !ctx.doubleConfirmationGranted) {
6317
+ const msg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
6318
+ return {
6319
+ text: msg,
6320
+ structuredOutput: null,
6321
+ state: "pending_double_confirmation"
6322
+ };
6323
+ }
6324
+ for (const quotaName of contract.quotasConsumed) {
6325
+ const check = await checkQuota(
6326
+ ctx.tenantId,
6327
+ quotaName
6328
+ );
6329
+ if (!check.ok) {
6330
+ await writeAuditLog({
6331
+ tenantId: ctx.tenantId,
6332
+ brandId: ctx.brandId ?? null,
6333
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6334
+ action: contract.auditAction,
6335
+ motivo: `Quota excedida \u2014 ${quotaName}: ${check.current}/${check.limit}`,
6336
+ conversacionId: ctx.conversacionId ?? null,
6337
+ status: "error",
6338
+ errorMessage: check.reason ?? "quota exceeded",
6339
+ durationMs: Date.now() - startMs
6340
+ });
6341
+ return {
6342
+ text: martinSafeError(new Error(check.reason ?? "quota"), locale),
6343
+ structuredOutput: null,
6344
+ state: "quota_exceeded"
6345
+ };
6346
+ }
6347
+ }
6348
+ let output;
6349
+ try {
6350
+ output = await helper(parsedInput);
6351
+ contract.outputSchema.parse(output);
6352
+ } catch (err) {
6353
+ await writeAuditLog({
6354
+ tenantId: ctx.tenantId,
6355
+ brandId: ctx.brandId ?? null,
6356
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6357
+ action: contract.auditAction,
6358
+ motivo: "Acci\xF3n solicitada v\xEDa Martin",
6359
+ conversacionId: ctx.conversacionId ?? null,
6360
+ status: "error",
6361
+ errorMessage: err?.message ?? "unknown",
6362
+ durationMs: Date.now() - startMs
6363
+ });
6364
+ return {
6365
+ text: martinSafeError(err, locale),
6366
+ structuredOutput: null,
6367
+ state: "error"
6368
+ };
6369
+ }
6370
+ const maybeDisabled = output;
6371
+ if (maybeDisabled.disabled === true) {
6372
+ const code = maybeDisabled.code;
6373
+ if (!code) {
6374
+ throw new Error(
6375
+ `Wrapper: helper "${contract.name}" retorn\xF3 disabled:true pero sin 'code'. Helper debe retornar { disabled:true, code, detail? }.`
6376
+ );
6377
+ }
6378
+ if (contract.disabledReasonCodes && !contract.disabledReasonCodes.includes(code)) {
6379
+ throw new Error(
6380
+ `Wrapper: helper "${contract.name}" retorn\xF3 disabled code "${code}" que NO est\xE1 declarado en contract.disabledReasonCodes [${contract.disabledReasonCodes.join(",")}]. Actualizar el contract antes de que el helper retorne c\xF3digos nuevos.`
6381
+ );
6382
+ }
6383
+ const detail = maybeDisabled.detail;
6384
+ const disabledText = getDisabledMessage(code, locale, detail);
6385
+ await writeAuditLog({
6386
+ tenantId: ctx.tenantId,
6387
+ brandId: ctx.brandId ?? null,
6388
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6389
+ action: contract.auditAction,
6390
+ motivo: `disabled \u2014 ${code}${detail ? `: ${JSON.stringify(detail)}` : ""}`,
6391
+ conversacionId: ctx.conversacionId ?? null,
6392
+ status: "partial",
6393
+ durationMs: Date.now() - startMs
6394
+ });
6395
+ return {
6396
+ text: disabledText,
6397
+ structuredOutput: output,
6398
+ state: "disabled",
6399
+ disabledReason: code
6400
+ };
6401
+ }
6402
+ const targetPath = contract.extractTargetPath ? contract.extractTargetPath(parsedInput, output) : "";
6403
+ const changes = contract.extractChanges ? contract.extractChanges(parsedInput, output) : { before: null, after: null };
6404
+ await writeAuditLog({
6405
+ tenantId: ctx.tenantId,
6406
+ brandId: ctx.brandId ?? null,
6407
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6408
+ action: contract.auditAction,
6409
+ targetPath,
6410
+ changes,
6411
+ motivo: "Acci\xF3n solicitada v\xEDa Martin",
6412
+ conversacionId: ctx.conversacionId ?? null,
6413
+ status: "success",
6414
+ durationMs: Date.now() - startMs
6415
+ });
6416
+ const summary = contract.martinSummaryTemplate(parsedInput, output, locale);
6417
+ return {
6418
+ text: summary,
6419
+ structuredOutput: output,
6420
+ state: "success"
6421
+ };
6422
+ };
6423
+ }
6424
+ var RecordarMemoriaParamsSchema = import_zod33.z.object({
6425
+ tipo: TipoMemoriaEnum,
6426
+ categoria: CategoriaMemoriaEnum,
6427
+ contenido: import_zod33.z.string().min(3).max(500)
6428
+ });
6429
+ var RecordarMemoriaOutputSchema = import_zod33.z.object({
6430
+ memoriaId: import_zod33.z.string(),
6431
+ status: import_zod33.z.literal("creada")
6432
+ });
6433
+ var OlvidarMemoriaParamsSchema = import_zod33.z.object({
6434
+ memoriaId: import_zod33.z.string(),
6435
+ motivo: import_zod33.z.string().optional()
6436
+ });
6437
+ var OlvidarMemoriaOutputSchema = import_zod33.z.object({
6438
+ status: import_zod33.z.literal("archivada")
6439
+ });
6440
+ var ConfigInputSchema = import_zod34.z.object({
6441
+ diaSemana: import_zod34.z.number().int().min(0).max(6).nullable(),
6442
+ diaMes: import_zod34.z.number().int().min(1).max(31).nullable(),
6443
+ hora: import_zod34.z.string().regex(/^\d{2}:\d{2}$/),
6444
+ // Timezone NO viene en input — hereda de tenants/{tid}.zonaHoraria (audit fix 2).
6445
+ fechaPuntual: import_zod34.z.string().datetime({ offset: true }).nullable()
6446
+ }).strict();
6447
+ var AccionInputSchema = import_zod34.z.object({
6448
+ tool: import_zod34.z.string().min(1),
6449
+ params: import_zod34.z.record(import_zod34.z.string(), import_zod34.z.unknown())
6450
+ }).strict();
6451
+ var ProgramarRutinaParamsSchema = import_zod34.z.object({
6452
+ tipo: TipoRutinaEnum,
6453
+ frecuencia: FrecuenciaRutinaEnum,
6454
+ config: ConfigInputSchema,
6455
+ accion: AccionInputSchema,
6456
+ uidDestinatario: import_zod34.z.string()
6457
+ });
6458
+ var ProgramarRutinaOutputSchema = import_zod34.z.object({
6459
+ rutinaId: import_zod34.z.string(),
6460
+ proximaEjecucionAt: import_zod34.z.string().datetime()
6461
+ });
6462
+ var PausarRutinaParamsSchema = import_zod34.z.object({
6463
+ rutinaId: import_zod34.z.string(),
6464
+ motivo: import_zod34.z.string().optional()
6465
+ });
6466
+ var PausarRutinaOutputSchema = import_zod34.z.object({
6467
+ status: import_zod34.z.literal("pausada")
6468
+ });
6469
+ var ArchivarRutinaParamsSchema = import_zod34.z.object({
6470
+ rutinaId: import_zod34.z.string(),
6471
+ motivo: import_zod34.z.string().optional()
6472
+ });
6473
+ var ArchivarRutinaOutputSchema = import_zod34.z.object({
6474
+ status: import_zod34.z.literal("archivada")
6475
+ });
6476
+ var ListarRutinasParamsSchema = import_zod34.z.object({
6477
+ uid: import_zod34.z.string().optional()
6478
+ });
6479
+ var ListarRutinasOutputSchema = import_zod34.z.object({
6480
+ rutinas: import_zod34.z.array(MartinRutinaSchema)
6481
+ });
6482
+
6483
+ // src/tools/martinContext.ts
6484
+ function buildMartinContext(session, brandId, opts = {}) {
6485
+ return {
6486
+ tenantId: session.requireTenant(),
6487
+ brandId,
6488
+ user: {
6489
+ uid: session.userId ?? "cowork-admin",
6490
+ nombre: session.userName ?? "Cowork Admin",
6491
+ rol: session.rol ?? "super_admin",
6492
+ idiomaPreferido: "es"
6493
+ // TODO HITO 8: leer usuarios/{uid}.idiomaPreferido
6494
+ },
6495
+ conversacionId: opts.conversacionId ?? null,
6496
+ confirmationGranted: opts.confirmationGranted ?? true,
6497
+ doubleConfirmationGranted: opts.doubleConfirmationGranted ?? false
6498
+ };
6499
+ }
6500
+ async function dispatchWithContract(args) {
6501
+ const { contract, helper, callable, input, ctx } = args;
6502
+ if (getMode() === "admin") {
6503
+ const db = getAdminDb();
6504
+ const wrapped = wrapWithContract(
6505
+ contract,
6506
+ async (i) => helper({ ...i, db })
6507
+ );
6508
+ return wrapped(input, ctx);
6509
+ }
6510
+ return callable({
6511
+ ...input,
6512
+ _martinContext: {
6513
+ conversacionId: ctx.conversacionId ?? null,
6514
+ confirmationGranted: ctx.confirmationGranted === true,
6515
+ doubleConfirmationGranted: ctx.doubleConfirmationGranted === true,
6516
+ userIdiomaPreferido: ctx.user.idiomaPreferido
6517
+ }
6518
+ });
6519
+ }
5140
6520
 
5141
6521
  // src/services/marketingHelperCallables.ts
5142
6522
  var import_app3 = require("firebase/app");
@@ -5156,6 +6536,9 @@ async function callCF(name, input) {
5156
6536
  const result = await fn(input);
5157
6537
  return result.data;
5158
6538
  }
6539
+ function callGetCalendar(input) {
6540
+ return callCF("marketingGetCalendarCallable", input);
6541
+ }
5159
6542
  function callBrandBriefWriter(input) {
5160
6543
  return callCF("marketingBrandBriefWriterCallable", input);
5161
6544
  }
@@ -5206,7 +6589,7 @@ function callPhotoDirectorExecute(input) {
5206
6589
  }
5207
6590
 
5208
6591
  // src/tools/marketing/photos.ts
5209
- var import_zod16 = require("zod");
6592
+ var import_zod36 = require("zod");
5210
6593
 
5211
6594
  // src/services/marketingEmbeddings.ts
5212
6595
  var import_google_auth_library = require("google-auth-library");
@@ -5292,7 +6675,7 @@ var MARKETING_THRESHOLDS = {
5292
6675
  gbp: 0.4
5293
6676
  },
5294
6677
  content: {
5295
- // find_content_for_topic busca text→text en shopify_products / collections /
6678
+ // find_content_for_topic busca text→text en products / collections /
5296
6679
  // articles / pages. Scores reales de top-10 andan 0.45-0.50, umbral 0.35
5297
6680
  // deja pasar los relevantes sin ser laxo.
5298
6681
  text: 0.35,
@@ -5379,12 +6762,12 @@ async function findNearestInCollection(params) {
5379
6762
  );
5380
6763
  }
5381
6764
  const db = getAdminDb();
5382
- const FieldValue = import_firebase_admin.default.firestore.FieldValue;
6765
+ const FieldValue3 = import_firebase_admin.default.firestore.FieldValue;
5383
6766
  const fetchLimit = extraFilters.length > 0 ? Math.max(limit * 4, 20) : limit;
5384
6767
  const baseQ = db.collection(collection).where("tenantId", "==", tenantId).where("brandId", "==", brandId);
5385
6768
  const q = baseQ.findNearest({
5386
6769
  vectorField,
5387
- queryVector: FieldValue.vector(queryEmbedding),
6770
+ queryVector: FieldValue3.vector(queryEmbedding),
5388
6771
  limit: fetchLimit,
5389
6772
  distanceMeasure: "COSINE",
5390
6773
  distanceResultField: "_distance"
@@ -5474,7 +6857,7 @@ async function findNearestInCollectionWithOverride(params) {
5474
6857
  }
5475
6858
 
5476
6859
  // src/tools/marketing/content.ts
5477
- var import_zod15 = require("zod");
6860
+ var import_zod35 = require("zod");
5478
6861
  var _logOverride = null;
5479
6862
  async function logToMcpLogs(entry) {
5480
6863
  if (_logOverride) return _logOverride(entry);
@@ -5487,22 +6870,22 @@ async function logToMcpLogs(entry) {
5487
6870
  } catch {
5488
6871
  }
5489
6872
  }
5490
- var IncludeSchema = import_zod15.z.object({
5491
- products: import_zod15.z.boolean().default(true),
5492
- collections: import_zod15.z.boolean().default(true),
5493
- articles: import_zod15.z.boolean().default(true),
5494
- pages: import_zod15.z.boolean().default(false)
6873
+ var IncludeSchema = import_zod35.z.object({
6874
+ products: import_zod35.z.boolean().default(true),
6875
+ collections: import_zod35.z.boolean().default(true),
6876
+ articles: import_zod35.z.boolean().default(true),
6877
+ pages: import_zod35.z.boolean().default(false)
5495
6878
  }).default({
5496
6879
  products: true,
5497
6880
  collections: true,
5498
6881
  articles: true,
5499
6882
  pages: false
5500
6883
  });
5501
- var LimitSchema = import_zod15.z.object({
5502
- products: import_zod15.z.number().int().min(0).max(20).default(5),
5503
- collections: import_zod15.z.number().int().min(0).max(10).default(3),
5504
- articles: import_zod15.z.number().int().min(0).max(20).default(5),
5505
- pages: import_zod15.z.number().int().min(0).max(10).default(2)
6884
+ var LimitSchema = import_zod35.z.object({
6885
+ products: import_zod35.z.number().int().min(0).max(20).default(5),
6886
+ collections: import_zod35.z.number().int().min(0).max(10).default(3),
6887
+ articles: import_zod35.z.number().int().min(0).max(20).default(5),
6888
+ pages: import_zod35.z.number().int().min(0).max(10).default(2)
5506
6889
  }).default({ products: 5, collections: 3, articles: 5, pages: 2 });
5507
6890
  async function findContentForTopicHandler(input, session) {
5508
6891
  const tenantId = session.requireTenant();
@@ -5588,13 +6971,13 @@ MODOS (parametro mode):
5588
6971
 
5589
6972
  Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = el doc aparecio en ambos modos (alta confianza). 1 = aparecio solo en text (problema visual) o solo en image (problema SEO). Esos "solo image" son candidatos perfectos para recomendar optimizacion al tenant.`,
5590
6973
  {
5591
- brandId: import_zod15.z.string().optional().describe("ID de la brand"),
5592
- contexto: import_zod15.z.string().min(1).describe("Parrafo, keyword o intencion"),
5593
- fecha: import_zod15.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
6974
+ brandId: import_zod35.z.string().optional().describe("ID de la brand"),
6975
+ contexto: import_zod35.z.string().min(1).describe("Parrafo, keyword o intencion"),
6976
+ fecha: import_zod35.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
5594
6977
  include: IncludeSchema.optional(),
5595
6978
  limit: LimitSchema.optional(),
5596
- diversidad: import_zod15.z.boolean().default(true),
5597
- mode: import_zod15.z.enum(["text", "hybrid"]).default("text").describe("Modo de busqueda: 'text' (rapido, 1 query) o 'hybrid' (text+image via RRF, 2x queries, rescata SEO debil)")
6979
+ diversidad: import_zod35.z.boolean().default(true),
6980
+ mode: import_zod35.z.enum(["text", "hybrid"]).default("text").describe("Modo de busqueda: 'text' (rapido, 1 query) o 'hybrid' (text+image via RRF, 2x queries, rescata SEO debil)")
5598
6981
  },
5599
6982
  async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode: mode2 }) => {
5600
6983
  const brandId = inputBrandId ?? session.requireBrand();
@@ -5698,11 +7081,11 @@ REGLAS:
5698
7081
 
5699
7082
  USAR: antes de generar contenido de cualquier slot del calendario.`,
5700
7083
  {
5701
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
5702
- keyword: import_zod16.z.string().describe("Keyword del slot"),
5703
- plataforma: import_zod16.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
5704
- fecha: import_zod16.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
5705
- limit: import_zod16.z.number().int().min(1).max(10).default(5)
7084
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7085
+ keyword: import_zod36.z.string().describe("Keyword del slot"),
7086
+ plataforma: import_zod36.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
7087
+ fecha: import_zod36.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
7088
+ limit: import_zod36.z.number().int().min(1).max(10).default(5)
5706
7089
  },
5707
7090
  async ({ brandId: inputBrandId, keyword, plataforma, fecha, limit }) => {
5708
7091
  const tenantId = session.requireTenant();
@@ -5743,15 +7126,17 @@ DESPUES de ver la foto, decide:
5743
7126
 
5744
7127
  Luego llama execute_photo_edit con tu analisis y prompt.`,
5745
7128
  {
5746
- fotoId: import_zod16.z.string().describe("ID de la foto")
7129
+ fotoId: import_zod36.z.string().describe("ID de la foto")
5747
7130
  },
5748
7131
  async ({ fotoId }) => {
5749
7132
  const tenantId = session.requireTenant();
7133
+ const lang = await resolveTenantIdioma(tenantId);
5750
7134
  const result = getMode() === "admin" ? await photoDirectorPlan({
5751
7135
  db: getAdminDb(),
5752
7136
  tenantId,
5753
7137
  fotoId,
5754
- deps: { compressImageForTransport }
7138
+ deps: { compressImageForTransport },
7139
+ lang
5755
7140
  }) : await callPhotoDirectorPlan({ tenantId, fotoId });
5756
7141
  if (!result.ok) {
5757
7142
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -5774,16 +7159,18 @@ Retorna la foto editada para que la revises. Si no te gusta:
5774
7159
 
5775
7160
  Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si se genera thumbnail y embedding con tus tags.`,
5776
7161
  {
5777
- fotoId: import_zod16.z.string(),
5778
- prompt: import_zod16.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
5779
- acciones: import_zod16.z.array(import_zod16.z.enum(["edit_background", "none"])),
5780
- descripcion: import_zod16.z.string().describe("Descripcion semantica en espanol"),
5781
- tipo: import_zod16.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
5782
- tagsPrimarios: import_zod16.z.array(import_zod16.z.string()),
5783
- tagsSecundarios: import_zod16.z.array(import_zod16.z.string()),
5784
- tagsContexto: import_zod16.z.array(import_zod16.z.string())
7162
+ fotoId: import_zod36.z.string(),
7163
+ prompt: import_zod36.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
7164
+ acciones: import_zod36.z.array(import_zod36.z.enum(["edit_background", "none"])),
7165
+ descripcion: import_zod36.z.string().describe("Descripcion semantica en espanol"),
7166
+ tipo: import_zod36.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
7167
+ tagsPrimarios: import_zod36.z.array(import_zod36.z.string()),
7168
+ tagsSecundarios: import_zod36.z.array(import_zod36.z.string()),
7169
+ tagsContexto: import_zod36.z.array(import_zod36.z.string())
5785
7170
  },
5786
7171
  async ({ fotoId, prompt, acciones, descripcion, tipo, tagsPrimarios, tagsSecundarios, tagsContexto }) => {
7172
+ const tenantId = session.requireTenant();
7173
+ const lang = await resolveTenantIdioma(tenantId);
5787
7174
  if (getMode() === "admin") {
5788
7175
  const executePhotoEditAdapter = async (payload) => {
5789
7176
  const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
@@ -5807,7 +7194,8 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
5807
7194
  tagsPrimarios,
5808
7195
  tagsSecundarios,
5809
7196
  tagsContexto,
5810
- deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter }
7197
+ deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter },
7198
+ lang
5811
7199
  });
5812
7200
  if (!result2.ok) {
5813
7201
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
@@ -5821,7 +7209,7 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
5821
7209
  return { content: contentArray2 };
5822
7210
  }
5823
7211
  const result = await callPhotoDirectorExecute({
5824
- tenantId: session.requireTenant(),
7212
+ tenantId,
5825
7213
  fotoId,
5826
7214
  prompt,
5827
7215
  acciones,
@@ -5855,7 +7243,7 @@ Retorna:
5855
7243
  - creditos: { balance, planId, periodEnd, costoPhotoEdit }
5856
7244
  - _instrucciones: que hacer segun el estado`,
5857
7245
  {
5858
- fotoId: import_zod16.z.string().describe("ID de la foto")
7246
+ fotoId: import_zod36.z.string().describe("ID de la foto")
5859
7247
  },
5860
7248
  async ({ fotoId }) => {
5861
7249
  const tenantId = session.requireTenant();
@@ -5898,7 +7286,7 @@ Retorna:
5898
7286
  };
5899
7287
  }
5900
7288
  }
5901
- const creditsDoc = await readDoc("brand_credits", `${tenantId}_${brandId}`);
7289
+ const creditsDoc = await readDoc(`tenants/${tenantId}/brand_credits`, brandId);
5902
7290
  const balance = creditsDoc?.balance ?? 0;
5903
7291
  const planId = creditsDoc?.planId ?? null;
5904
7292
  const periodEnd = creditsDoc?.periodEnd ?? null;
@@ -5955,11 +7343,11 @@ Retorna:
5955
7343
  "find_products_for_content",
5956
7344
  `[DEPRECATED 179.5] Wrapper interno de find_content_for_topic. Para nuevas integraciones, usa find_content_for_topic con include.products=true directamente. Este wrapper existe solo por compat hacia atras.`,
5957
7345
  {
5958
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
5959
- contexto: import_zod16.z.string().describe("Parrafo, keyword o intencion del contenido"),
5960
- fecha: import_zod16.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
5961
- limit: import_zod16.z.number().int().min(1).max(10).default(5),
5962
- diversidad: import_zod16.z.boolean().default(true)
7346
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7347
+ contexto: import_zod36.z.string().describe("Parrafo, keyword o intencion del contenido"),
7348
+ fecha: import_zod36.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7349
+ limit: import_zod36.z.number().int().min(1).max(10).default(5),
7350
+ diversidad: import_zod36.z.boolean().default(true)
5963
7351
  },
5964
7352
  async ({ brandId: inputBrandId, contexto, fecha, limit, diversidad }) => {
5965
7353
  console.warn(
@@ -6010,12 +7398,12 @@ Retorna:
6010
7398
 
6011
7399
  RETORNA { plantillaId, titulo, thumbnailUrl } o { plantillaId: null, motivo: 'no_canva' } si tenant no tiene Canva conectado.
6012
7400
 
6013
- USAR: solo si tenant tiene Canva conectado (marketing_config.brands[brandId].canva.connected=true).`,
7401
+ USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/{brandId}.canva.connected=true).`,
6014
7402
  {
6015
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
6016
- plataforma: import_zod16.z.string().describe("gbp | instagram | shopify_blog"),
6017
- tipoContenido: import_zod16.z.string().describe("post | carousel | story | blog"),
6018
- keyword: import_zod16.z.string().describe("Keyword del slot")
7403
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7404
+ plataforma: import_zod36.z.string().describe("gbp | instagram | shopify_blog"),
7405
+ tipoContenido: import_zod36.z.string().describe("post | carousel | story | blog"),
7406
+ keyword: import_zod36.z.string().describe("Keyword del slot")
6019
7407
  },
6020
7408
  async ({ brandId: inputBrandId, plataforma, tipoContenido, keyword }) => {
6021
7409
  const tenantId = session.requireTenant();
@@ -6045,15 +7433,15 @@ USAR: cuando get_photos_for_slot retorna pocas fotos y el slot aun no esta cubie
6045
7433
 
6046
7434
  IMPORTANTE \u2014 campo 'razon': Este texto lo ve el tenant en la app. Escribe en lenguaje amigable y claro, NO tecnico. Ejemplo MALO: "get_photos_for_slot retorno 0 fotos para shopify_blog". Ejemplo BUENO: "No hay fotos de alcatraz para el blog del 8 de abril". Siempre en espanol si el tenant habla espanol.`,
6047
7435
  {
6048
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
6049
- semana: import_zod16.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "semana debe ser ISO YYYY-MM-DD").describe("Semana en ISO del lunes"),
6050
- necesidades: import_zod16.z.array(
6051
- import_zod16.z.object({
6052
- tema: import_zod16.z.string(),
6053
- keyword: import_zod16.z.string(),
6054
- cantidadSugerida: import_zod16.z.number().int().positive(),
6055
- razon: import_zod16.z.string(),
6056
- slotsAfectados: import_zod16.z.array(import_zod16.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
7436
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7437
+ semana: import_zod36.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "semana debe ser ISO YYYY-MM-DD").describe("Semana en ISO del lunes"),
7438
+ necesidades: import_zod36.z.array(
7439
+ import_zod36.z.object({
7440
+ tema: import_zod36.z.string(),
7441
+ keyword: import_zod36.z.string(),
7442
+ cantidadSugerida: import_zod36.z.number().int().positive(),
7443
+ razon: import_zod36.z.string(),
7444
+ slotsAfectados: import_zod36.z.array(import_zod36.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
6057
7445
  })
6058
7446
  ).min(1)
6059
7447
  },
@@ -6192,16 +7580,6 @@ async function linkContenidoAPasoPipeline(params) {
6192
7580
  }
6193
7581
 
6194
7582
  // src/tools/marketing.ts
6195
- async function resolveLastImportId3(tenantId, brandId) {
6196
- const config = await readDoc("marketing_config", tenantId);
6197
- const brands = config?.brands ?? {};
6198
- const brand = brands[brandId];
6199
- const canalId = brand?.canalId;
6200
- if (!canalId) return null;
6201
- const canal = await readDoc("canales_venta", canalId);
6202
- const app = canal?.app ?? {};
6203
- return app.lastImportId ?? null;
6204
- }
6205
7583
  function registerMarketingTools(server, session) {
6206
7584
  registerPhotoTools(server, session);
6207
7585
  registerContentTools(server, session);
@@ -6209,48 +7587,46 @@ function registerMarketingTools(server, session) {
6209
7587
  "get_calendar",
6210
7588
  "Lee el calendario editorial del mes. Muestra semanas con items planificados por plataforma.",
6211
7589
  {
6212
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6213
- mes: import_zod17.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
7590
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7591
+ mes: import_zod37.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
6214
7592
  },
6215
7593
  async ({ brandId: inputBrandId, mes }) => {
6216
- session.requireTenant();
7594
+ const tenantId = session.requireTenant();
6217
7595
  const brandId = inputBrandId ?? session.requireBrand();
6218
7596
  const targetMes = mes ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
6219
- const calendarios = await queryByTenant(session, "marketing_calendario", [
6220
- { field: "brandId", op: "==", value: brandId },
6221
- { field: "mes", op: "==", value: targetMes }
6222
- ], { limit: 1 });
6223
- if (calendarios.length === 0) {
6224
- return {
6225
- content: [{
6226
- type: "text",
6227
- text: JSON.stringify({
6228
- mes: targetMes,
6229
- brandId,
6230
- semanas: [],
6231
- mensaje: "No hay calendario para este mes. Genera un plan de marketing primero."
6232
- })
6233
- }]
7597
+ const ctx = buildMartinContext(session, brandId);
7598
+ const result = await dispatchWithContract({
7599
+ contract: getCalendarContract,
7600
+ helper: getCalendar,
7601
+ callable: callGetCalendar,
7602
+ input: { tenantId, brandId, mes: targetMes },
7603
+ ctx
7604
+ });
7605
+ let payload;
7606
+ if (result.state === "success" && result.structuredOutput) {
7607
+ const out = result.structuredOutput;
7608
+ payload = out.calendario ?? {
7609
+ mes: out.mes,
7610
+ brandId: out.brandId,
7611
+ semanas: [],
7612
+ mensaje: out.mensaje
6234
7613
  };
7614
+ } else {
7615
+ payload = { ok: false, state: result.state, mensaje: result.text };
6235
7616
  }
6236
- return { content: [{ type: "text", text: JSON.stringify(calendarios[0], null, 2) }] };
7617
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
6237
7618
  }
6238
7619
  );
6239
7620
  server.tool(
6240
7621
  "get_seo_snapshot",
6241
7622
  "Lee el snapshot SEO de la brand: rank, keywords, oportunidades, competidores.",
6242
7623
  {
6243
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7624
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6244
7625
  },
6245
7626
  async ({ brandId: inputBrandId }) => {
6246
7627
  const tenantId = session.requireTenant();
6247
7628
  const brandId = inputBrandId ?? session.requireBrand();
6248
- const config = await readDoc("marketing_config", tenantId);
6249
- if (!config) {
6250
- return { content: [{ type: "text", text: JSON.stringify({ error: "No hay marketing_config" }) }] };
6251
- }
6252
- const brands = config.brands ?? {};
6253
- const brand = brands[brandId];
7629
+ const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
6254
7630
  if (!brand) {
6255
7631
  return { content: [{ type: "text", text: JSON.stringify({ error: `Brand "${brandId}" no encontrada` }) }] };
6256
7632
  }
@@ -6264,8 +7640,8 @@ function registerMarketingTools(server, session) {
6264
7640
  "get_photo_gallery",
6265
7641
  "Fotos disponibles filtradas por estado. Muestra fotos con metadata y conteo.",
6266
7642
  {
6267
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6268
- estado: import_zod17.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
7643
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7644
+ estado: import_zod37.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
6269
7645
  },
6270
7646
  async ({ brandId: inputBrandId, estado }) => {
6271
7647
  session.requireTenant();
@@ -6305,7 +7681,7 @@ function registerMarketingTools(server, session) {
6305
7681
  "generate_marketing_plan",
6306
7682
  "Prepara los datos necesarios para generar un plan de marketing estrategico. Retorna snapshot SEO + productos para que Claude genere el plan con el system prompt.",
6307
7683
  {
6308
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7684
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6309
7685
  },
6310
7686
  async ({ brandId: inputBrandId }) => {
6311
7687
  const tenantId = session.requireTenant();
@@ -6317,25 +7693,33 @@ function registerMarketingTools(server, session) {
6317
7693
  );
6318
7694
  server.tool(
6319
7695
  "save_marketing_plan",
6320
- "Guarda el plan de marketing generado por Claude en la configuracion de la brand. Escribe en marketing_config.brands[brandId].plan.",
7696
+ "Guarda el plan de marketing generado por Claude en la configuracion de la brand. Escribe en tenants/{tenantId}/marketing_config/{brandId}.plan.",
6321
7697
  {
6322
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6323
- plan: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).describe("Plan de marketing generado (keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, etc.)")
7698
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7699
+ plan: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Plan de marketing generado (keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, etc.)")
6324
7700
  },
6325
7701
  async ({ brandId: inputBrandId, plan }) => {
6326
7702
  const tenantId = session.requireTenant();
6327
7703
  const brandId = inputBrandId ?? session.requireBrand();
6328
- const result = getMode() === "admin" ? await planWriter.save({ db: getAdminDb(), tenantId, brandId, plan }) : await callPlanWriterSave({ tenantId, brandId, plan });
6329
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
7704
+ const ctx = buildMartinContext(session, brandId);
7705
+ const result = await dispatchWithContract({
7706
+ contract: planWriterSaveContract,
7707
+ helper: planWriter.save,
7708
+ callable: callPlanWriterSave,
7709
+ input: { tenantId, brandId, plan },
7710
+ ctx
7711
+ });
7712
+ const payload = result.state === "success" ? result.structuredOutput : { ok: false, state: result.state, mensaje: result.text };
7713
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6330
7714
  }
6331
7715
  );
6332
7716
  server.tool(
6333
7717
  "update_marketing_plan_field",
6334
7718
  "Actualiza UN campo del plan de marketing sin sobreescribir el resto. Merge parcial. Ideal para agregar coleccionesPriorizadas, actualizar quickWins, etc.",
6335
7719
  {
6336
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6337
- field: import_zod17.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
6338
- value: import_zod17.z.unknown().describe("Valor del campo")
7720
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7721
+ field: import_zod37.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
7722
+ value: import_zod37.z.unknown().describe("Valor del campo")
6339
7723
  },
6340
7724
  async ({ brandId: inputBrandId, field, value }) => {
6341
7725
  const tenantId = session.requireTenant();
@@ -6348,22 +7732,36 @@ function registerMarketingTools(server, session) {
6348
7732
  "generate_brand_brief",
6349
7733
  "Prepara todos los datos del negocio para que Claude genere un Brand Brief pre-llenado. Retorna Shopify + SEO + GBP + tenant data. Claude genera el brief, luego usa save_brand_brief para guardar.",
6350
7734
  {
6351
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7735
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6352
7736
  },
6353
7737
  async ({ brandId: inputBrandId }) => {
6354
7738
  const tenantId = session.requireTenant();
6355
7739
  const brandId = inputBrandId ?? session.requireBrand();
6356
- const result = getMode() === "admin" ? await brandBriefBuilder({ db: getAdminDb(), tenantId, brandId }) : await callBrandBriefBuilder({ tenantId, brandId });
6357
- const payload = result.ok ? result.payload : result;
6358
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7740
+ const ctx = buildMartinContext(session, brandId);
7741
+ const result = await dispatchWithContract({
7742
+ contract: brandBriefBuilderContract,
7743
+ helper: brandBriefBuilder,
7744
+ callable: callBrandBriefBuilder,
7745
+ input: { tenantId, brandId },
7746
+ ctx
7747
+ });
7748
+ let returnPayload;
7749
+ if (result.state === "success" && result.structuredOutput?.ok === true) {
7750
+ returnPayload = result.structuredOutput.payload;
7751
+ } else if (result.state === "success") {
7752
+ returnPayload = result.structuredOutput;
7753
+ } else {
7754
+ returnPayload = { ok: false, state: result.state, mensaje: result.text };
7755
+ }
7756
+ return { content: [{ type: "text", text: JSON.stringify(returnPayload, null, 2) }] };
6359
7757
  }
6360
7758
  );
6361
7759
  server.tool(
6362
7760
  "save_brand_brief",
6363
- "Guarda el Brand Brief generado por Claude en marketing_config.brands[brandId].brandBrief. Escribe solo ese campo (merge parcial, no rebuild del doc).",
7761
+ "Guarda el Brand Brief generado por Claude en tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Escribe solo ese campo (merge parcial, no rebuild del doc).",
6364
7762
  {
6365
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6366
- brandBrief: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).describe("Brand Brief completo generado por Claude")
7763
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7764
+ brandBrief: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Brand Brief completo generado por Claude")
6367
7765
  },
6368
7766
  async ({ brandId: inputBrandId, brandBrief }) => {
6369
7767
  const tenantId = session.requireTenant();
@@ -6376,9 +7774,9 @@ function registerMarketingTools(server, session) {
6376
7774
  "generate_weekly_content",
6377
7775
  "Prepara datos del calendario + fotos + plan para generar el contenido de la semana. Claude genera con el system prompt, luego usa save_generated_content para guardar.",
6378
7776
  {
6379
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6380
- semana: import_zod17.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
6381
- modo: import_zod17.z.enum(["planificar", "generar"]).optional().describe("'planificar' = proponer distribucion (plataforma+keyword por dia). 'generar' = generar contenido real para slots ya planificados. Default: 'planificar'.")
7777
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7778
+ semana: import_zod37.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
7779
+ modo: import_zod37.z.enum(["planificar", "generar"]).optional().describe("'planificar' = proponer distribucion (plataforma+keyword por dia). 'generar' = generar contenido real para slots ya planificados. Default: 'planificar'.")
6382
7780
  },
6383
7781
  async ({ brandId: inputBrandId, semana, modo }) => {
6384
7782
  const tenantId = session.requireTenant();
@@ -6394,14 +7792,14 @@ function registerMarketingTools(server, session) {
6394
7792
 
6395
7793
  IMPORTANTE: Si seleccionaste una foto con get_photos_for_slot, SIEMPRE pasa fotoId aqu\xED. Sin fotoId el post se publica sin imagen.`,
6396
7794
  {
6397
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6398
- plataforma: import_zod17.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
6399
- tipo: import_zod17.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
6400
- keyword: import_zod17.z.string().optional().describe("Keyword target"),
6401
- languageCode: import_zod17.z.string().optional().describe("Idioma (es/en)"),
6402
- fotoId: import_zod17.z.string().optional().describe("ID de la foto a asociar"),
6403
- datos: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
6404
- calendarioItemRef: import_zod17.z.string().optional().describe("Referencia al item del calendario")
7795
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7796
+ plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
7797
+ tipo: import_zod37.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
7798
+ keyword: import_zod37.z.string().optional().describe("Keyword target"),
7799
+ languageCode: import_zod37.z.string().optional().describe("Idioma (es/en)"),
7800
+ fotoId: import_zod37.z.string().optional().describe("ID de la foto a asociar"),
7801
+ datos: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
7802
+ calendarioItemRef: import_zod37.z.string().optional().describe("Referencia al item del calendario")
6405
7803
  },
6406
7804
  async ({ brandId: inputBrandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef }) => {
6407
7805
  const tenantId = session.requireTenant();
@@ -6446,13 +7844,13 @@ Usa para: corregir body, metaTitle, tags, fotoId, o cualquier campo sin tener qu
6446
7844
  NO puede cambiar: tenantId, brandId, id (inmutables).
6447
7845
  Si pasas campos dentro de "datos", se hace merge con los datos existentes (no los reemplaza entero).`,
6448
7846
  {
6449
- contenidoId: import_zod17.z.string().describe("ID del doc en marketing_contenido"),
6450
- datos: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).optional().describe('Campos de datos a actualizar (merge parcial sobre datos existentes). Ej: { body: "...", metaTitle: "..." }'),
6451
- fotoId: import_zod17.z.string().nullable().optional().describe("Actualizar foto asociada"),
6452
- keyword: import_zod17.z.string().nullable().optional().describe("Actualizar keyword"),
6453
- languageCode: import_zod17.z.string().optional().describe("Actualizar idioma"),
6454
- estado: import_zod17.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
6455
- calendarioItemRef: import_zod17.z.string().nullable().optional().describe("Vincular a un slot del calendario")
7847
+ contenidoId: import_zod37.z.string().describe("ID del doc en marketing_contenido"),
7848
+ datos: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).optional().describe('Campos de datos a actualizar (merge parcial sobre datos existentes). Ej: { body: "...", metaTitle: "..." }'),
7849
+ fotoId: import_zod37.z.string().nullable().optional().describe("Actualizar foto asociada"),
7850
+ keyword: import_zod37.z.string().nullable().optional().describe("Actualizar keyword"),
7851
+ languageCode: import_zod37.z.string().optional().describe("Actualizar idioma"),
7852
+ estado: import_zod37.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
7853
+ calendarioItemRef: import_zod37.z.string().nullable().optional().describe("Vincular a un slot del calendario")
6456
7854
  },
6457
7855
  async ({ contenidoId, datos: newDatos, fotoId, keyword, languageCode, estado, calendarioItemRef }) => {
6458
7856
  const tenantId = session.requireTenant();
@@ -6483,27 +7881,27 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
6483
7881
  "update_calendar_slot",
6484
7882
  "Modifica o agrega un slot del calendario editorial. Si slotIndex >= items.length, agrega un slot nuevo.",
6485
7883
  {
6486
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6487
- mes: import_zod17.z.string().describe("Mes del calendario (YYYY-MM)"),
6488
- semana: import_zod17.z.number().describe("Numero de semana (1-5)"),
6489
- slotIndex: import_zod17.z.number().describe("Indice del slot (0-based). Si >= items.length, agrega nuevo slot al final."),
6490
- cambios: import_zod17.z.object({
6491
- dia: import_zod17.z.string().nullable().optional(),
6492
- plataforma: import_zod17.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional(),
6493
- tipo: import_zod17.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional(),
6494
- keyword: import_zod17.z.string().nullable().optional(),
6495
- tema: import_zod17.z.string().nullable().optional(),
6496
- productoId: import_zod17.z.string().nullable().optional(),
6497
- estado: import_zod17.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
6498
- contenidoRef: import_zod17.z.string().nullable().optional(),
6499
- fotoIdAsignada: import_zod17.z.string().nullable().optional(),
6500
- notas: import_zod17.z.array(NotaCalendarioSchema).optional(),
6501
- locationId: import_zod17.z.string().nullable().optional().describe("GBP location ID \u2014 a qu\xE9 sucursal publicar el post"),
6502
- locationNombre: import_zod17.z.string().nullable().optional().describe("Nombre de la sucursal GBP (para UI)")
7884
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7885
+ mes: import_zod37.z.string().describe("Mes del calendario (YYYY-MM)"),
7886
+ semana: import_zod37.z.number().describe("Numero de semana (1-5)"),
7887
+ slotIndex: import_zod37.z.number().describe("Indice del slot (0-based). Si >= items.length, agrega nuevo slot al final."),
7888
+ cambios: import_zod37.z.object({
7889
+ dia: import_zod37.z.string().nullable().optional(),
7890
+ plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional(),
7891
+ tipo: import_zod37.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional(),
7892
+ keyword: import_zod37.z.string().nullable().optional(),
7893
+ tema: import_zod37.z.string().nullable().optional(),
7894
+ productoId: import_zod37.z.string().nullable().optional(),
7895
+ estado: import_zod37.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
7896
+ contenidoRef: import_zod37.z.string().nullable().optional(),
7897
+ fotoIdAsignada: import_zod37.z.string().nullable().optional(),
7898
+ notas: import_zod37.z.array(NotaCalendarioSchema).optional(),
7899
+ locationId: import_zod37.z.string().nullable().optional().describe("GBP location ID \u2014 a qu\xE9 sucursal publicar el post"),
7900
+ locationNombre: import_zod37.z.string().nullable().optional().describe("Nombre de la sucursal GBP (para UI)")
6503
7901
  }).strict().describe("Campos del slot. Solo acepta campos validos del schema. Campos no reconocidos seran rechazados."),
6504
- accionContenidoExistente: import_zod17.z.union([
6505
- import_zod17.z.enum(["descartar", "nuevo_slot", "mantener"]),
6506
- import_zod17.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Formato: mover:semana:N:slot:M")
7902
+ accionContenidoExistente: import_zod37.z.union([
7903
+ import_zod37.z.enum(["descartar", "nuevo_slot", "mantener"]),
7904
+ import_zod37.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Formato: mover:semana:N:slot:M")
6507
7905
  ]).optional().describe(
6508
7906
  "Decision del tenant cuando el slot ya tiene un contenidoRef existente Y los cambios tocan keyword/tema/plataforma/tipo. Requerido en ese escenario (si no se pasa, el helper retorna ACCION_CONTENIDO_EXISTENTE_REQUIRED con las 4 opciones para que preguntes al tenant). Valores: 'descartar' (marca el contenido existente como descartado y aplica cambios al slot) | 'mover:semana:N:slot:M' (mueve el contenido existente al slot destino vacio y aplica cambios al slot origen) | 'nuevo_slot' (NO toca este slot ni su contenido; crea un slot nuevo el mismo dia con los cambios \u2014 util para multiples publicaciones/dia) | 'mantener' (conserva el contenido existente aqui y aplica los cambios igualmente \u2014 caso typo fix)."
6509
7907
  )
@@ -6511,9 +7909,16 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
6511
7909
  async ({ brandId: inputBrandId, mes, semana, slotIndex, cambios, accionContenidoExistente }) => {
6512
7910
  const tenantId = session.requireTenant();
6513
7911
  const brandId = inputBrandId ?? session.requireBrand();
6514
- const accionTyped = accionContenidoExistente;
6515
- const result = getMode() === "admin" ? await calendarSlotUpdater({ db: getAdminDb(), tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente: accionTyped }) : await callCalendarSlotUpdater({ tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente });
6516
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
7912
+ const ctx = buildMartinContext(session, brandId);
7913
+ const result = await dispatchWithContract({
7914
+ contract: calendarSlotUpdaterContract,
7915
+ helper: calendarSlotUpdater,
7916
+ callable: callCalendarSlotUpdater,
7917
+ input: { tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente },
7918
+ ctx
7919
+ });
7920
+ const payload = result.state === "success" ? result.structuredOutput : { ok: false, state: result.state, mensaje: result.text };
7921
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6517
7922
  }
6518
7923
  );
6519
7924
  server.tool(
@@ -6526,9 +7931,9 @@ ESCRIBE EN DOS LUGARES:
6526
7931
 
6527
7932
  NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agenda, no en el post.`,
6528
7933
  {
6529
- contenidoRef: import_zod17.z.string().describe("ID del doc en marketing_contenido"),
6530
- fotoId: import_zod17.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
6531
- calendarioItemRef: import_zod17.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
7934
+ contenidoRef: import_zod37.z.string().describe("ID del doc en marketing_contenido"),
7935
+ fotoId: import_zod37.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
7936
+ calendarioItemRef: import_zod37.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
6532
7937
  },
6533
7938
  async ({ contenidoRef, fotoId, calendarioItemRef }) => {
6534
7939
  const tenantId = session.requireTenant();
@@ -6541,7 +7946,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6541
7946
  "approve_content",
6542
7947
  "Aprueba contenido para publicacion. Valida transicion de estado.",
6543
7948
  {
6544
- contenidoId: import_zod17.z.string().describe("ID del contenido a aprobar")
7949
+ contenidoId: import_zod37.z.string().describe("ID del contenido a aprobar")
6545
7950
  },
6546
7951
  async ({ contenidoId }) => {
6547
7952
  session.requireTenant();
@@ -6565,8 +7970,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6565
7970
  "reject_content",
6566
7971
  "Rechaza contenido con motivo. Valida transicion de estado.",
6567
7972
  {
6568
- contenidoId: import_zod17.z.string().describe("ID del contenido a rechazar"),
6569
- motivo: import_zod17.z.string().describe("Motivo del rechazo")
7973
+ contenidoId: import_zod37.z.string().describe("ID del contenido a rechazar"),
7974
+ motivo: import_zod37.z.string().describe("Motivo del rechazo")
6570
7975
  },
6571
7976
  async ({ contenidoId, motivo }) => {
6572
7977
  session.requireTenant();
@@ -6586,44 +7991,44 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6586
7991
  }
6587
7992
  );
6588
7993
  server.tool(
6589
- "get_shopify_collections",
6590
- "Lee todas las colecciones de Shopify con sus 6 campos SEO actuales. Incluye best practices 2026.",
7994
+ "get_collections",
7995
+ "Lee todas las colecciones canonicas (Shopify/WordPress/webonly) con sus 6 campos SEO actuales. Incluye best practices 2026.",
6591
7996
  {
6592
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7997
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6593
7998
  },
6594
7999
  async ({ brandId: inputBrandId }) => {
6595
8000
  const tenantId = session.requireTenant();
6596
8001
  const brandId = inputBrandId ?? session.requireBrand();
6597
- const lastImportId = await resolveLastImportId3(tenantId, brandId);
6598
- if (!lastImportId) {
8002
+ const db = getAdminDb();
8003
+ const collSnap = await db.collection(`tenants/${tenantId}/marketing_snapshots/${brandId}/collections`).get();
8004
+ const items = collSnap.docs.map((d) => d.data());
8005
+ if (items.length === 0) {
6599
8006
  return { content: [{ type: "text", text: JSON.stringify({
6600
8007
  ok: false,
6601
- error: "No hay import de Shopify. El tenant debe sincronizar desde Marketing > Mi Plan > Shopify."
8008
+ error: "No hay colecciones nested. El tenant debe sincronizar desde Marketing > Configuraci\xF3n > Shopify > Sincronizar."
6602
8009
  }) }] };
6603
8010
  }
6604
- const collectionsDoc = await readDoc(
6605
- `tenants/${tenantId}/shopify_imports/${lastImportId}/data`,
6606
- "collections"
6607
- );
6608
- const items = collectionsDoc?.items ?? [];
6609
- const config = await readDoc("marketing_config", tenantId);
6610
- const brands = config?.brands ?? {};
6611
- const brand = brands[brandId];
8011
+ const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
6612
8012
  const plan = brand?.plan ?? {};
6613
8013
  const keywords = plan.keywordsPrioritarios ?? [];
6614
8014
  const existingSuggestions = brand?.collectionSuggestions ?? {};
6615
- const collections = items.map((c) => ({
6616
- id: c.id,
6617
- title: c.title,
6618
- handle: c.handle,
6619
- body_html: typeof c.body_html === "string" ? c.body_html.slice(0, 500) : null,
6620
- metaTitle: c.metaTitle ?? null,
6621
- metaDescription: c.metaDescription ?? null,
6622
- products_count: c.products_count ?? null,
6623
- collectionType: c.collectionType ?? null,
6624
- image: c.image ? { src: c.image.src, alt: c.image.alt } : null,
6625
- existingSuggestion: existingSuggestions[String(c.id)] ?? null
6626
- }));
8015
+ const collections = items.map((c) => {
8016
+ const seo = c.seo || {};
8017
+ const featured = c.featuredImage || null;
8018
+ return {
8019
+ id: c.platformId,
8020
+ title: c.title,
8021
+ handle: c.handle,
8022
+ body_html: typeof c.descriptionHtml === "string" ? c.descriptionHtml.slice(0, 500) : null,
8023
+ metaTitle: seo.metaTitle ?? null,
8024
+ metaDescription: seo.metaDescription ?? null,
8025
+ products_count: null,
8026
+ // adapter v2 aun no calcula products_count por collection
8027
+ collectionType: "canonical",
8028
+ image: featured ? { src: featured.url, alt: featured.altText } : null,
8029
+ existingSuggestion: existingSuggestions[String(c.platformId)] ?? null
8030
+ };
8031
+ });
6627
8032
  return { content: [{ type: "text", text: JSON.stringify({
6628
8033
  ok: true,
6629
8034
  totalCollections: collections.length,
@@ -6676,7 +8081,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6676
8081
  "save_collection_suggestions",
6677
8082
  "Guarda sugerencias SEO para colecciones de Shopify. El tenant las aprueba en la UI.",
6678
8083
  {
6679
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
8084
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
6680
8085
  suggestions: CollectionSuggestionsInputArraySchema.describe("Array de sugerencias SEO. Cada una con max 60 chars en metaTitle, max 158 en metaDescription, max 125 en imageAlt")
6681
8086
  },
6682
8087
  async ({ brandId: inputBrandId, suggestions }) => {