ponch-mcp-server 1.0.71 → 1.0.73

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` };
@@ -2713,7 +3465,7 @@ async function calendarSlotUpdater(input) {
2713
3465
  if (slotIndex < 0) {
2714
3466
  return { ok: false, error: "slotIndex no puede ser negativo" };
2715
3467
  }
2716
- 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();
2717
3469
  if (calQuery.empty) {
2718
3470
  return { ok: false, error: "Calendario no encontrado" };
2719
3471
  }
@@ -2750,7 +3502,7 @@ async function calendarSlotUpdater(input) {
2750
3502
  }
2751
3503
  let contenidosADescartar = [];
2752
3504
  if (oldContenidoRef && (accionContenidoExistente === "descartar" || !tocaSemantica)) {
2753
- 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();
2754
3506
  contenidosADescartar = contenidoQuery.docs.filter((c) => {
2755
3507
  const data = c.data();
2756
3508
  return data.estado !== "descartado" && data.estado !== ESTADO_CONTENIDO.PUBLICADO;
@@ -2810,7 +3562,7 @@ async function calendarSlotUpdater(input) {
2810
3562
  let movedSlotEstado = null;
2811
3563
  if (moveTarget && oldContenidoRef) {
2812
3564
  try {
2813
- const contenidoSnap = await db.collection("marketing_contenido").doc(oldContenidoRef).get();
3565
+ const contenidoSnap = await db.doc(`tenants/${tenantId}/marketing_contenido/${oldContenidoRef}`).get();
2814
3566
  if (contenidoSnap.exists) {
2815
3567
  const estadoContenido = contenidoSnap.data().estado;
2816
3568
  movedSlotEstado = mapContenidoEstadoToSlotEstado(estadoContenido);
@@ -2883,7 +3635,7 @@ async function calendarSlotUpdater(input) {
2883
3635
  });
2884
3636
  if (moveTarget && oldContenidoRef) {
2885
3637
  try {
2886
- await db.collection("marketing_contenido").doc(oldContenidoRef).update({
3638
+ await db.doc(`tenants/${tenantId}/marketing_contenido/${oldContenidoRef}`).update({
2887
3639
  calendarioItemRef: moveTarget.targetRef
2888
3640
  });
2889
3641
  } catch (err) {
@@ -2982,6 +3734,134 @@ async function handleNuevoSlot(args) {
2982
3734
  descartados: 0
2983
3735
  };
2984
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
+ );
2985
3865
  var PLATAFORMA_A_FORMATO = {
2986
3866
  gbp: "gbp_4_3",
2987
3867
  shopify_blog: "blog_3_2",
@@ -2990,7 +3870,7 @@ var PLATAFORMA_A_FORMATO = {
2990
3870
  };
2991
3871
  async function photoAssigner(input) {
2992
3872
  const { db, tenantId, brandId, contenidoRef, fotoId, calendarioItemRef } = input;
2993
- const contenidoRefDoc = db.collection("marketing_contenido").doc(contenidoRef);
3873
+ const contenidoRefDoc = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoRef}`);
2994
3874
  const contenidoSnap = await contenidoRefDoc.get();
2995
3875
  if (!contenidoSnap.exists) {
2996
3876
  return { ok: false, error: `Contenido ${contenidoRef} no encontrado` };
@@ -2999,7 +3879,7 @@ async function photoAssigner(input) {
2999
3879
  if (contenido.tenantId !== tenantId) {
3000
3880
  return { ok: false, error: "Contenido no pertenece a este tenant" };
3001
3881
  }
3002
- 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();
3003
3883
  if (fotoQuery.empty) {
3004
3884
  return { ok: false, error: `Foto ${fotoId} no encontrada` };
3005
3885
  }
@@ -3031,7 +3911,7 @@ async function photoAssigner(input) {
3031
3911
  if (match) {
3032
3912
  const semanaNum = parseInt(match[1], 10);
3033
3913
  const slotIdx = parseInt(match[2], 10);
3034
- 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();
3035
3915
  for (const calDoc of calQuery.docs) {
3036
3916
  const cal = calDoc.data();
3037
3917
  const semanas = cal.semanas ?? [];
@@ -3088,101 +3968,87 @@ async function photoAssigner(input) {
3088
3968
  formato
3089
3969
  };
3090
3970
  }
3091
- async function resolveLastImportId(db, tenantId, brandId) {
3092
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3093
- const config = configSnap.data();
3094
- const brands = config?.brands ?? {};
3095
- const brand = brands[brandId];
3096
- const canalId = brand?.canalId;
3097
- if (!canalId) return null;
3098
- const canalSnap = await db.collection("canales_venta").doc(canalId).get();
3099
- const canal = canalSnap.data();
3100
- const app = canal?.app ?? {};
3101
- 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;
3102
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;
3103
3976
  async function brandBriefBuilder(input) {
3104
3977
  const { db, tenantId, brandId } = input;
3105
- 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();
3106
3979
  if (!configSnap.exists) {
3107
- return { ok: false, error: "No hay marketing_config" };
3108
- }
3109
- const config = configSnap.data();
3110
- const brands = config.brands ?? {};
3111
- const brand = brands[brandId];
3112
- if (!brand) {
3113
3980
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
3114
3981
  }
3115
- const [lastImportId, tenantSnap] = await Promise.all([
3116
- 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),
3117
3989
  db.collection("tenants").doc(tenantId).get()
3118
3990
  ]);
3119
3991
  const tenantDoc = tenantSnap.data();
3120
- let shopProducts = [];
3121
- let shopCollections = [];
3122
- let shopOrders = null;
3123
- let shopInfo = null;
3124
- if (lastImportId) {
3125
- const basePath = `tenants/${tenantId}/shopify_imports/${lastImportId}/data`;
3126
- const [prodSnap, colSnap, ordSnap, infoSnap] = await Promise.all([
3127
- db.doc(`${basePath}/products`).get().catch(() => null),
3128
- db.doc(`${basePath}/collections`).get().catch(() => null),
3129
- db.doc(`${basePath}/orders_summary`).get().catch(() => null),
3130
- db.doc(`${basePath}/shop_info`).get().catch(() => null)
3131
- ]);
3132
- const prodDoc = prodSnap?.exists ? prodSnap.data() : null;
3133
- const colDoc = colSnap?.exists ? colSnap.data() : null;
3134
- const ordDoc = ordSnap?.exists ? ordSnap.data() : null;
3135
- const infoDoc = infoSnap?.exists ? infoSnap.data() : null;
3136
- 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 {
3137
4009
  title: p.title,
3138
- product_type: p.product_type,
3139
- tags: p.tags,
3140
- price_min: p.price_min ?? p.variants?.[0]?.price,
3141
- price_max: p.price_max,
3142
- status: p.status
3143
- }));
3144
- shopCollections = (colDoc?.items ?? []).map((c) => ({
3145
- 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,
3146
4022
  title: c.title,
3147
4023
  handle: c.handle,
3148
- products_count: c.products_count,
3149
- collectionType: c.collectionType
3150
- }));
3151
- shopOrders = ordDoc ? {
3152
- totalOrders: ordDoc.totalOrders,
3153
- averageOrderValue: ordDoc.averageOrderValue,
3154
- topProducts: ordDoc.topProducts,
3155
- repeatRate: ordDoc.repeatRate,
3156
- currency: ordDoc.currency
3157
- } : null;
3158
- shopInfo = infoDoc ? {
3159
- name: infoDoc.name,
3160
- domain: infoDoc.domain,
3161
- myshopifyDomain: infoDoc.myshopifyDomain,
3162
- country: infoDoc.country,
3163
- currency: infoDoc.currency,
3164
- timezone: infoDoc.timezone,
3165
- plan: infoDoc.plan_name ?? infoDoc.plan,
3166
- languages: infoDoc.languages,
3167
- shippingZones: infoDoc.shippingZones
3168
- } : null;
3169
- }
3170
- const siteSnap = await db.collection("shopify_site_content").doc(`${tenantId}_${brandId}`).get().catch(() => null);
3171
- const siteContentDoc = siteSnap?.exists ? siteSnap.data() : null;
3172
- const sitePages = siteContentDoc?.pages || {};
3173
- const home = sitePages["/"] || {};
3174
- const aboutEn = sitePages["/pages/about"] || {};
3175
- const aboutEs = sitePages["/pages/nosotros"] || {};
3176
- const faqEn = sitePages["/pages/faq"] || {};
3177
- const faqEs = sitePages["/pages/preguntas-frecuentes"] || {};
3178
- const sitePayload = siteContentDoc ? {
3179
- homeTitle: home.title || null,
3180
- homeMeta: home.metaDescription || null,
3181
- homeH1: home.h1 || null,
3182
- homeOgDescription: home.ogDescription || null,
3183
- aboutFirstParagraph: aboutEn.firstParagraph || aboutEs.firstParagraph || null,
3184
- aboutH1: aboutEn.h1 || aboutEs.h1 || null,
3185
- 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
3186
4052
  } : null;
3187
4053
  const seo = brand.seoSnapshot;
3188
4054
  const gbpPerfiles = brand.gbpPerfiles ?? [];
@@ -3215,14 +4081,20 @@ async function brandBriefBuilder(input) {
3215
4081
  }
3216
4082
  const topTags = Object.entries(tagCount).sort(([, a], [, b]) => b - a).slice(0, 20).map(([tag, count]) => ({ tag, count }));
3217
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.`;
3218
- 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
+ }));
3219
4090
  const payload = {
3220
4091
  instruccion: instruccionBase + instruccionSiteContent,
3221
- _siteContentVersion: siteContentDoc?.importId || null,
4092
+ _lastImportId: lastImportId,
3222
4093
  _schema: BRAND_BRIEF_SCHEMA_HINT,
3223
4094
  datosDisponibles: {
3224
4095
  shopInfo,
3225
4096
  sitePayload,
4097
+ availablePages,
3226
4098
  precioStats,
3227
4099
  productTypes: typeCount,
3228
4100
  topTags,
@@ -3283,11 +4155,51 @@ var BRAND_BRIEF_SCHEMA_HINT = {
3283
4155
  escenas: [{ id: string, nombre: string, promptHint: string }]
3284
4156
  }`
3285
4157
  };
3286
- async function resolveLastImportId2(db, tenantId, brandId) {
3287
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3288
- const config = configSnap.data();
3289
- const brands = config?.brands ?? {};
3290
- 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();
3291
4203
  const canalId = brand?.canalId;
3292
4204
  if (!canalId) return null;
3293
4205
  const canalSnap = await db.collection("canales_venta").doc(canalId).get();
@@ -3297,16 +4209,11 @@ async function resolveLastImportId2(db, tenantId, brandId) {
3297
4209
  }
3298
4210
  async function marketingPlanBuilder(input) {
3299
4211
  const { db, tenantId, brandId } = input;
3300
- 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();
3301
4213
  if (!configSnap.exists) {
3302
- return { ok: false, error: "No hay marketing_config" };
3303
- }
3304
- const config = configSnap.data();
3305
- const brands = config.brands ?? {};
3306
- const brand = brands[brandId];
3307
- if (!brand) {
3308
4214
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
3309
4215
  }
4216
+ const brand = configSnap.data();
3310
4217
  if (!brand.seoSnapshot) {
3311
4218
  return {
3312
4219
  ok: false,
@@ -3315,9 +4222,9 @@ async function marketingPlanBuilder(input) {
3315
4222
  }
3316
4223
  const productosQ = await db.collection("productos").where("tenantId", "==", tenantId).limit(100).get();
3317
4224
  const productos = productosQ.docs.map((d) => d.data());
3318
- 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();
3319
4226
  const historial = histQ.docs.map((d) => d.data());
3320
- const lastImportId = await resolveLastImportId2(db, tenantId, brandId);
4227
+ const lastImportId = await resolveLastImportId(db, tenantId, brandId);
3321
4228
  let colecciones = [];
3322
4229
  let shopifyBlogs = [];
3323
4230
  if (lastImportId) {
@@ -3492,13 +4399,11 @@ async function contenidoWriter(input) {
3492
4399
  calendarioItemRef,
3493
4400
  linkPipeline
3494
4401
  } = input;
3495
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
3496
- const config = configSnap.data();
3497
- const brands = config?.brands ?? {};
3498
- 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;
3499
4404
  const frecuencia = brand?.frecuencia ?? {};
3500
4405
  const estadosQueOcupanSlot = ["aprobado", "publicado"];
3501
- 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();
3502
4407
  const contenidoEstaSemana = contenidoQ.docs.map((d2) => ({ id: d2.id, ...d2.data() }));
3503
4408
  const now = /* @__PURE__ */ new Date();
3504
4409
  const startOfWeek = new Date(now);
@@ -3531,7 +4436,7 @@ async function contenidoWriter(input) {
3531
4436
  (c) => c.calendarioItemRef === calendarioItemRef && c.estado !== "descartado"
3532
4437
  );
3533
4438
  for (const existente of existentes) {
3534
- await db.collection("marketing_contenido").doc(existente.id).update({
4439
+ await db.doc(`tenants/${tenantId}/marketing_contenido/${existente.id}`).update({
3535
4440
  estado: "descartado",
3536
4441
  rechazadoMotivo: "Reemplazado por contenido nuevo para el mismo slot",
3537
4442
  rechazadoAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp()
@@ -3575,6 +4480,11 @@ async function contenidoWriter(input) {
3575
4480
  }
3576
4481
  }
3577
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
+ }
3578
4488
  const contenido = buildContenido({
3579
4489
  id,
3580
4490
  tenantId,
@@ -3582,7 +4492,7 @@ async function contenidoWriter(input) {
3582
4492
  plataforma,
3583
4493
  tipo: tipo ?? "post",
3584
4494
  keyword: keyword ?? null,
3585
- languageCode: languageCode ?? "es",
4495
+ languageCode,
3586
4496
  estado: ESTADO_CONTENIDO.PENDIENTE_APROBACION,
3587
4497
  fotoId: fotoId ?? null,
3588
4498
  mediaUrl: null,
@@ -3592,14 +4502,14 @@ async function contenidoWriter(input) {
3592
4502
  creadoPorId: "mcp-cowork",
3593
4503
  origen: "ai_assisted"
3594
4504
  });
3595
- const contenidoRef = db.collection("marketing_contenido").doc(id);
4505
+ const contenidoRef = db.doc(`tenants/${tenantId}/marketing_contenido/${id}`);
3596
4506
  let slotResolved = null;
3597
4507
  if (calendarioItemRef) {
3598
4508
  const match = calendarioItemRef.match(/^semana:(\d+):slot:(\d+)$/);
3599
4509
  if (match) {
3600
4510
  const semanaNum = parseInt(match[1], 10);
3601
4511
  const slotIdx = parseInt(match[2], 10);
3602
- 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();
3603
4513
  for (const calDoc of calQ.docs) {
3604
4514
  const cal = calDoc.data();
3605
4515
  const semanas = cal.semanas ?? [];
@@ -3748,14 +4658,12 @@ async function weeklyContentBuilder(input) {
3748
4658
  const now = /* @__PURE__ */ new Date();
3749
4659
  const targetMes = now.toISOString().slice(0, 7);
3750
4660
  const targetSemana = semana ?? Math.ceil(now.getDate() / 7);
3751
- 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();
3752
4662
  if (!configSnap.exists) {
3753
- return { ok: false, error: "No hay marketing_config" };
4663
+ return { ok: false, error: `Brand "${brandId}" no encontrada` };
3754
4664
  }
3755
- const config = configSnap.data();
3756
- const brands = config.brands ?? {};
3757
- const brand = brands[brandId];
3758
- 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();
3759
4667
  let calendario = calQuery.empty ? null : { id: calQuery.docs[0].id, ...calQuery.docs[0].data() };
3760
4668
  if (!calendario) {
3761
4669
  const calId = `${tenantId}_${brandId}_${targetMes}`;
@@ -3770,14 +4678,14 @@ async function weeklyContentBuilder(input) {
3770
4678
  creadoPorId: "mcp-cowork",
3771
4679
  updatedAt: null
3772
4680
  };
3773
- await db.collection("marketing_calendario").doc(calId).set(calDoc);
4681
+ await db.doc(`tenants/${tenantId}/marketing_calendario/${calId}`).set(calDoc);
3774
4682
  calendario = { ...calDoc, semanas };
3775
4683
  }
3776
4684
  const semanasArr = calendario.semanas ?? [];
3777
4685
  const semanaData = semanasArr[targetSemana - 1] ?? null;
3778
4686
  const slotsExistentes = semanaData?.items ?? [];
3779
4687
  if (targetModo === "planificar") {
3780
- 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();
3781
4689
  const historial2 = histQ2.docs.map((d) => d.data());
3782
4690
  return {
3783
4691
  ok: true,
@@ -3835,9 +4743,9 @@ async function weeklyContentBuilder(input) {
3835
4743
  }
3836
4744
  };
3837
4745
  }
3838
- 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();
3839
4747
  const fotosDisponibles = fotosQ.docs.map((d) => d.data());
3840
- 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();
3841
4749
  const historial = histQ.docs.map((d) => d.data());
3842
4750
  const hasBlogSlots = slotsParaGenerar.some((s) => s.plataforma === "shopify_blog");
3843
4751
  let blogJIT = null;
@@ -3845,7 +4753,7 @@ async function weeklyContentBuilder(input) {
3845
4753
  const blogStrategy = brand.blogStrategy;
3846
4754
  const blogs = blogStrategy?.blogs ?? [];
3847
4755
  const idiomas = brand.idiomas;
3848
- 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();
3849
4757
  const articulosExistentes = articulosQ.docs.map(
3850
4758
  (d) => d.data()
3851
4759
  );
@@ -3881,7 +4789,15 @@ async function weeklyContentBuilder(input) {
3881
4789
  userId: blogStrategy?.defaultAuthorId ?? null,
3882
4790
  name: blogStrategy?.defaultAuthorName ?? null
3883
4791
  },
3884
- 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
+ })()
3885
4801
  });
3886
4802
  }
3887
4803
  blogJIT = {
@@ -4014,10 +4930,8 @@ async function contentFinder(input) {
4014
4930
  const textThreshold = deps.thresholds?.text ?? DEFAULT_TEXT_THRESHOLD;
4015
4931
  const imageThreshold = deps.thresholds?.image ?? DEFAULT_IMAGE_THRESHOLD;
4016
4932
  const conflictThreshold = deps.thresholds?.conflictSimilarity ?? DEFAULT_CONFLICT_THRESHOLD;
4017
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
4018
- const config = configSnap.exists ? configSnap.data() : null;
4019
- const brands = config?.brands ?? {};
4020
- 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;
4021
4935
  const brandBrief = brand?.brandBrief ?? null;
4022
4936
  const visualRules = brandBrief?.visualRules ?? {};
4023
4937
  const plan = brand?.plan ?? null;
@@ -4074,22 +4988,22 @@ async function contentFinder(input) {
4074
4988
  const productsLimit = include.products ? collFilter ? limit.products * 4 : limit.products * 2 : 0;
4075
4989
  const [productsRaw, collectionsRaw, articlesRaw, pagesRaw] = await Promise.all([
4076
4990
  searchCollection(
4077
- "shopify_products",
4991
+ "products",
4078
4992
  include.products && limit.products > 0,
4079
4993
  productsLimit
4080
4994
  ),
4081
4995
  searchCollection(
4082
- "shopify_collections",
4996
+ "collections",
4083
4997
  include.collections && limit.collections > 0,
4084
4998
  limit.collections * 2
4085
4999
  ),
4086
5000
  searchCollection(
4087
- "shopify_articles",
5001
+ "articles",
4088
5002
  include.articles && limit.articles > 0,
4089
5003
  limit.articles * 2
4090
5004
  ),
4091
5005
  searchCollection(
4092
- "shopify_pages",
5006
+ "pages",
4093
5007
  include.pages && limit.pages > 0,
4094
5008
  limit.pages * 2
4095
5009
  )
@@ -4194,16 +5108,11 @@ async function slotAssetFinder(input) {
4194
5108
  const limit = input.limit ?? 5;
4195
5109
  const photoThreshold = deps.thresholds?.photos ?? DEFAULT_PHOTO_THRESHOLD;
4196
5110
  const shopifyThreshold = deps.thresholds?.shopify ?? DEFAULT_SHOPIFY_THRESHOLD;
4197
- 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();
4198
5112
  if (!configSnap.exists) {
4199
- return { ok: false, error: `Brand "${brandId}" no encontrada (no marketing_config)` };
4200
- }
4201
- const config = configSnap.data();
4202
- const brands = config.brands ?? {};
4203
- const brand = brands[brandId] ?? null;
4204
- if (!brand) {
4205
5113
  return { ok: false, error: `Brand "${brandId}" no encontrada` };
4206
5114
  }
5115
+ const brand = configSnap.data();
4207
5116
  const brandBrief = brand.brandBrief ?? null;
4208
5117
  const visualRules = brandBrief?.visualRules ?? {};
4209
5118
  const plan = brand.plan ?? null;
@@ -4229,7 +5138,7 @@ async function slotAssetFinder(input) {
4229
5138
  try {
4230
5139
  const fetchLimit = bloqueoProducto && temporada?.coleccion?.handle ? limit * 4 : limit;
4231
5140
  const nearest = await deps.findNearestInCollection({
4232
- collection: "shopify_products",
5141
+ collection: "products",
4233
5142
  tenantId,
4234
5143
  brandId,
4235
5144
  queryEmbedding: queryEmbedding ?? void 0,
@@ -4331,16 +5240,11 @@ function cosineSimilarity(a, b) {
4331
5240
  async function canvaTemplateSelector(input) {
4332
5241
  const { db, tenantId, brandId, plataforma, tipoContenido, keyword, deps } = input;
4333
5242
  const threshold = deps.threshold ?? DEFAULT_SIMILARITY_THRESHOLD;
4334
- 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();
4335
5244
  if (!configSnap.exists) {
4336
5245
  return { plantillaId: null, motivo: "brand_no_encontrada" };
4337
5246
  }
4338
- const config = configSnap.data();
4339
- const brands = config.brands ?? {};
4340
- const brand = brands[brandId] ?? null;
4341
- if (!brand) {
4342
- return { plantillaId: null, motivo: "brand_no_encontrada" };
4343
- }
5247
+ const brand = configSnap.data();
4344
5248
  const canva = brand.canva ?? {};
4345
5249
  if (!canva.connected || !Array.isArray(canva.templates) || canva.templates.length === 0) {
4346
5250
  return {
@@ -4498,7 +5402,7 @@ var ERROR_MESSAGES = {
4498
5402
  en: () => `External infrastructure failure. You can retry ONCE. If it persists, report the error and try another photo.`
4499
5403
  }
4500
5404
  };
4501
- function instruccionesParaError(code, details, lang = "es") {
5405
+ function instruccionesParaError(code, details, lang) {
4502
5406
  const messages = ERROR_MESSAGES[code];
4503
5407
  if (!messages) {
4504
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.`;
@@ -4507,16 +5411,14 @@ function instruccionesParaError(code, details, lang = "es") {
4507
5411
  }
4508
5412
  async function photoDirectorPlan(input) {
4509
5413
  const { db, tenantId, fotoId, deps } = input;
4510
- 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();
4511
5415
  if (fotoQ.empty) {
4512
5416
  return { ok: false, code: "FOTO_NOT_FOUND", error: `Foto ${fotoId} no encontrada` };
4513
5417
  }
4514
5418
  const foto = fotoQ.docs[0].data();
4515
5419
  const brandId = foto.brandId;
4516
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
4517
- const config = configSnap.exists ? configSnap.data() : null;
4518
- const brands = config?.brands ?? {};
4519
- 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;
4520
5422
  const brandBrief = (brand?.brandBrief ?? null) || {};
4521
5423
  const visualRules = brandBrief.visualRules ?? {};
4522
5424
  const catalogoVisual = brandBrief.catalogoVisual;
@@ -4943,15 +5845,12 @@ Campos comunes de save_generated_content:
4943
5845
  tipo: string (del slot)
4944
5846
  `;
4945
5847
  async function buildTenantContext(db, tenantId, brandId) {
4946
- 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();
4947
5849
  if (!configSnap.exists) return "";
4948
- const config = configSnap.data();
4949
- const brands = config.brands ?? {};
4950
- const brand = brands[brandId];
4951
- if (!brand) return "";
5850
+ const brand = configSnap.data();
4952
5851
  const [fotosQ, contenidoQ, productosQ] = await Promise.all([
4953
- db.collection("marketing_fotos").where("tenantId", "==", tenantId).where("brandId", "==", brandId).where("estado", "==", ESTADO_FOTO.EDITADA).limit(10).get(),
4954
- 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(),
4955
5854
  db.collection("productos").where("tenantId", "==", tenantId).limit(50).get()
4956
5855
  ]);
4957
5856
  const fotosEditadas = fotosQ.docs.map((d) => d.data());
@@ -4976,7 +5875,12 @@ Dominio: ${brand.dominio ?? "sin dominio"}
4976
5875
  ctx += `Tono: ${plan?.tonoMarca ?? "profesional y cercano"}
4977
5876
  `;
4978
5877
  if (idiomas) {
4979
- 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}
4980
5884
  `;
4981
5885
  if (idiomas.distribucion) {
4982
5886
  ctx += `Distribucion de idiomas: ${JSON.stringify(idiomas.distribucion)}
@@ -5155,10 +6059,8 @@ async function buildSystemPrompt(input) {
5155
6059
  const p2 = PARTE_2_REGLAS.replace("{{SHAPES_BLOCK}}", shapesBlock);
5156
6060
  return p1 + p2;
5157
6061
  }
5158
- const configSnap = await db.collection("marketing_config").doc(tenantId).get();
5159
- const config = configSnap.exists ? configSnap.data() : null;
5160
- const brands = config?.brands ?? {};
5161
- 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;
5162
6064
  let prompt = PARTE_1_IDENTIDAD.replace(
5163
6065
  "{{brand_nombre}}",
5164
6066
  brand?.nombre ?? brandId
@@ -5170,6 +6072,452 @@ async function buildSystemPrompt(input) {
5170
6072
  }
5171
6073
  return prompt;
5172
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 (userRol === "super_admin") return true;
6264
+ if (required.includes("*")) return true;
6265
+ return required.includes(userRol);
6266
+ }
6267
+ function wrapWithContract(contract, helper) {
6268
+ return async function wrappedTool(input, ctx) {
6269
+ const startMs = Date.now();
6270
+ const locale = ctx.user.idiomaPreferido;
6271
+ if (!hasRequiredRole(ctx.user.rol, contract.requiredRoles)) {
6272
+ await writeAuditLog({
6273
+ tenantId: ctx.tenantId,
6274
+ brandId: ctx.brandId ?? null,
6275
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6276
+ action: contract.auditAction,
6277
+ motivo: `Acceso denegado \u2014 rol '${ctx.user.rol}' no autorizado`,
6278
+ conversacionId: ctx.conversacionId ?? null,
6279
+ status: "error",
6280
+ errorMessage: `Required: ${contract.requiredRoles.join(",")}, got: ${ctx.user.rol}`,
6281
+ durationMs: Date.now() - startMs
6282
+ });
6283
+ return {
6284
+ text: getWrapperMessage("denied_role", locale),
6285
+ structuredOutput: null,
6286
+ state: "denied_role"
6287
+ };
6288
+ }
6289
+ const parseResult = contract.paramsSchema.safeParse(input);
6290
+ if (!parseResult.success) {
6291
+ await writeAuditLog({
6292
+ tenantId: ctx.tenantId,
6293
+ brandId: ctx.brandId ?? null,
6294
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6295
+ action: contract.auditAction,
6296
+ motivo: "Input inv\xE1lido \u2014 Zod parse failed",
6297
+ conversacionId: ctx.conversacionId ?? null,
6298
+ status: "error",
6299
+ errorMessage: `Input shape inv\xE1lido: ${parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
6300
+ durationMs: Date.now() - startMs
6301
+ });
6302
+ return {
6303
+ text: getWrapperMessage("input_invalido", locale),
6304
+ structuredOutput: null,
6305
+ state: "error"
6306
+ };
6307
+ }
6308
+ const parsedInput = parseResult.data;
6309
+ if (contract.requiresConfirmation && !ctx.confirmationGranted) {
6310
+ const confirmMsg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
6311
+ return {
6312
+ text: confirmMsg,
6313
+ structuredOutput: null,
6314
+ state: "pending_confirmation"
6315
+ };
6316
+ }
6317
+ if (contract.requiresDoubleConfirmation && !ctx.doubleConfirmationGranted) {
6318
+ const msg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
6319
+ return {
6320
+ text: msg,
6321
+ structuredOutput: null,
6322
+ state: "pending_double_confirmation"
6323
+ };
6324
+ }
6325
+ for (const quotaName of contract.quotasConsumed) {
6326
+ const check = await checkQuota(
6327
+ ctx.tenantId,
6328
+ quotaName
6329
+ );
6330
+ if (!check.ok) {
6331
+ await writeAuditLog({
6332
+ tenantId: ctx.tenantId,
6333
+ brandId: ctx.brandId ?? null,
6334
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6335
+ action: contract.auditAction,
6336
+ motivo: `Quota excedida \u2014 ${quotaName}: ${check.current}/${check.limit}`,
6337
+ conversacionId: ctx.conversacionId ?? null,
6338
+ status: "error",
6339
+ errorMessage: check.reason ?? "quota exceeded",
6340
+ durationMs: Date.now() - startMs
6341
+ });
6342
+ return {
6343
+ text: martinSafeError(new Error(check.reason ?? "quota"), locale),
6344
+ structuredOutput: null,
6345
+ state: "quota_exceeded"
6346
+ };
6347
+ }
6348
+ }
6349
+ let output;
6350
+ try {
6351
+ output = await helper(parsedInput);
6352
+ contract.outputSchema.parse(output);
6353
+ } catch (err) {
6354
+ await writeAuditLog({
6355
+ tenantId: ctx.tenantId,
6356
+ brandId: ctx.brandId ?? null,
6357
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6358
+ action: contract.auditAction,
6359
+ motivo: "Acci\xF3n solicitada v\xEDa Martin",
6360
+ conversacionId: ctx.conversacionId ?? null,
6361
+ status: "error",
6362
+ errorMessage: err?.message ?? "unknown",
6363
+ durationMs: Date.now() - startMs
6364
+ });
6365
+ return {
6366
+ text: martinSafeError(err, locale),
6367
+ structuredOutput: null,
6368
+ state: "error"
6369
+ };
6370
+ }
6371
+ const maybeDisabled = output;
6372
+ if (maybeDisabled.disabled === true) {
6373
+ const code = maybeDisabled.code;
6374
+ if (!code) {
6375
+ throw new Error(
6376
+ `Wrapper: helper "${contract.name}" retorn\xF3 disabled:true pero sin 'code'. Helper debe retornar { disabled:true, code, detail? }.`
6377
+ );
6378
+ }
6379
+ if (contract.disabledReasonCodes && !contract.disabledReasonCodes.includes(code)) {
6380
+ throw new Error(
6381
+ `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.`
6382
+ );
6383
+ }
6384
+ const detail = maybeDisabled.detail;
6385
+ const disabledText = getDisabledMessage(code, locale, detail);
6386
+ await writeAuditLog({
6387
+ tenantId: ctx.tenantId,
6388
+ brandId: ctx.brandId ?? null,
6389
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6390
+ action: contract.auditAction,
6391
+ motivo: `disabled \u2014 ${code}${detail ? `: ${JSON.stringify(detail)}` : ""}`,
6392
+ conversacionId: ctx.conversacionId ?? null,
6393
+ status: "partial",
6394
+ durationMs: Date.now() - startMs
6395
+ });
6396
+ return {
6397
+ text: disabledText,
6398
+ structuredOutput: output,
6399
+ state: "disabled",
6400
+ disabledReason: code
6401
+ };
6402
+ }
6403
+ const targetPath = contract.extractTargetPath ? contract.extractTargetPath(parsedInput, output) : "";
6404
+ const changes = contract.extractChanges ? contract.extractChanges(parsedInput, output) : { before: null, after: null };
6405
+ await writeAuditLog({
6406
+ tenantId: ctx.tenantId,
6407
+ brandId: ctx.brandId ?? null,
6408
+ actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6409
+ action: contract.auditAction,
6410
+ targetPath,
6411
+ changes,
6412
+ motivo: "Acci\xF3n solicitada v\xEDa Martin",
6413
+ conversacionId: ctx.conversacionId ?? null,
6414
+ status: "success",
6415
+ durationMs: Date.now() - startMs
6416
+ });
6417
+ const summary = contract.martinSummaryTemplate(parsedInput, output, locale);
6418
+ return {
6419
+ text: summary,
6420
+ structuredOutput: output,
6421
+ state: "success"
6422
+ };
6423
+ };
6424
+ }
6425
+ var RecordarMemoriaParamsSchema = import_zod33.z.object({
6426
+ tipo: TipoMemoriaEnum,
6427
+ categoria: CategoriaMemoriaEnum,
6428
+ contenido: import_zod33.z.string().min(3).max(500)
6429
+ });
6430
+ var RecordarMemoriaOutputSchema = import_zod33.z.object({
6431
+ memoriaId: import_zod33.z.string(),
6432
+ status: import_zod33.z.literal("creada")
6433
+ });
6434
+ var OlvidarMemoriaParamsSchema = import_zod33.z.object({
6435
+ memoriaId: import_zod33.z.string(),
6436
+ motivo: import_zod33.z.string().optional()
6437
+ });
6438
+ var OlvidarMemoriaOutputSchema = import_zod33.z.object({
6439
+ status: import_zod33.z.literal("archivada")
6440
+ });
6441
+ var ConfigInputSchema = import_zod34.z.object({
6442
+ diaSemana: import_zod34.z.number().int().min(0).max(6).nullable(),
6443
+ diaMes: import_zod34.z.number().int().min(1).max(31).nullable(),
6444
+ hora: import_zod34.z.string().regex(/^\d{2}:\d{2}$/),
6445
+ // Timezone NO viene en input — hereda de tenants/{tid}.zonaHoraria (audit fix 2).
6446
+ fechaPuntual: import_zod34.z.string().datetime({ offset: true }).nullable()
6447
+ }).strict();
6448
+ var AccionInputSchema = import_zod34.z.object({
6449
+ tool: import_zod34.z.string().min(1),
6450
+ params: import_zod34.z.record(import_zod34.z.string(), import_zod34.z.unknown())
6451
+ }).strict();
6452
+ var ProgramarRutinaParamsSchema = import_zod34.z.object({
6453
+ tipo: TipoRutinaEnum,
6454
+ frecuencia: FrecuenciaRutinaEnum,
6455
+ config: ConfigInputSchema,
6456
+ accion: AccionInputSchema,
6457
+ uidDestinatario: import_zod34.z.string()
6458
+ });
6459
+ var ProgramarRutinaOutputSchema = import_zod34.z.object({
6460
+ rutinaId: import_zod34.z.string(),
6461
+ proximaEjecucionAt: import_zod34.z.string().datetime()
6462
+ });
6463
+ var PausarRutinaParamsSchema = import_zod34.z.object({
6464
+ rutinaId: import_zod34.z.string(),
6465
+ motivo: import_zod34.z.string().optional()
6466
+ });
6467
+ var PausarRutinaOutputSchema = import_zod34.z.object({
6468
+ status: import_zod34.z.literal("pausada")
6469
+ });
6470
+ var ArchivarRutinaParamsSchema = import_zod34.z.object({
6471
+ rutinaId: import_zod34.z.string(),
6472
+ motivo: import_zod34.z.string().optional()
6473
+ });
6474
+ var ArchivarRutinaOutputSchema = import_zod34.z.object({
6475
+ status: import_zod34.z.literal("archivada")
6476
+ });
6477
+ var ListarRutinasParamsSchema = import_zod34.z.object({
6478
+ uid: import_zod34.z.string().optional()
6479
+ });
6480
+ var ListarRutinasOutputSchema = import_zod34.z.object({
6481
+ rutinas: import_zod34.z.array(MartinRutinaSchema)
6482
+ });
6483
+
6484
+ // src/tools/martinContext.ts
6485
+ function buildMartinContext(session, brandId, opts = {}) {
6486
+ return {
6487
+ tenantId: session.requireTenant(),
6488
+ brandId,
6489
+ user: {
6490
+ uid: session.userId ?? "cowork-admin",
6491
+ nombre: session.userName ?? "Cowork Admin",
6492
+ rol: session.rol ?? "super_admin",
6493
+ idiomaPreferido: "es"
6494
+ // TODO HITO 8: leer usuarios/{uid}.idiomaPreferido
6495
+ },
6496
+ conversacionId: opts.conversacionId ?? null,
6497
+ confirmationGranted: opts.confirmationGranted ?? true,
6498
+ doubleConfirmationGranted: opts.doubleConfirmationGranted ?? false
6499
+ };
6500
+ }
6501
+ async function dispatchWithContract(args) {
6502
+ const { contract, helper, callable, input, ctx } = args;
6503
+ if (getMode() === "admin") {
6504
+ const db = getAdminDb();
6505
+ const wrapped = wrapWithContract(
6506
+ contract,
6507
+ async (i) => helper({ ...i, db })
6508
+ );
6509
+ return wrapped(input, ctx);
6510
+ }
6511
+ return callable({
6512
+ ...input,
6513
+ _martinContext: {
6514
+ conversacionId: ctx.conversacionId ?? null,
6515
+ confirmationGranted: ctx.confirmationGranted === true,
6516
+ doubleConfirmationGranted: ctx.doubleConfirmationGranted === true,
6517
+ userIdiomaPreferido: ctx.user.idiomaPreferido
6518
+ }
6519
+ });
6520
+ }
5173
6521
 
5174
6522
  // src/services/marketingHelperCallables.ts
5175
6523
  var import_app3 = require("firebase/app");
@@ -5189,6 +6537,9 @@ async function callCF(name, input) {
5189
6537
  const result = await fn(input);
5190
6538
  return result.data;
5191
6539
  }
6540
+ function callGetCalendar(input) {
6541
+ return callCF("marketingGetCalendarCallable", input);
6542
+ }
5192
6543
  function callBrandBriefWriter(input) {
5193
6544
  return callCF("marketingBrandBriefWriterCallable", input);
5194
6545
  }
@@ -5239,7 +6590,7 @@ function callPhotoDirectorExecute(input) {
5239
6590
  }
5240
6591
 
5241
6592
  // src/tools/marketing/photos.ts
5242
- var import_zod16 = require("zod");
6593
+ var import_zod36 = require("zod");
5243
6594
 
5244
6595
  // src/services/marketingEmbeddings.ts
5245
6596
  var import_google_auth_library = require("google-auth-library");
@@ -5325,7 +6676,7 @@ var MARKETING_THRESHOLDS = {
5325
6676
  gbp: 0.4
5326
6677
  },
5327
6678
  content: {
5328
- // find_content_for_topic busca text→text en shopify_products / collections /
6679
+ // find_content_for_topic busca text→text en products / collections /
5329
6680
  // articles / pages. Scores reales de top-10 andan 0.45-0.50, umbral 0.35
5330
6681
  // deja pasar los relevantes sin ser laxo.
5331
6682
  text: 0.35,
@@ -5412,12 +6763,12 @@ async function findNearestInCollection(params) {
5412
6763
  );
5413
6764
  }
5414
6765
  const db = getAdminDb();
5415
- const FieldValue = import_firebase_admin.default.firestore.FieldValue;
6766
+ const FieldValue3 = import_firebase_admin.default.firestore.FieldValue;
5416
6767
  const fetchLimit = extraFilters.length > 0 ? Math.max(limit * 4, 20) : limit;
5417
6768
  const baseQ = db.collection(collection).where("tenantId", "==", tenantId).where("brandId", "==", brandId);
5418
6769
  const q = baseQ.findNearest({
5419
6770
  vectorField,
5420
- queryVector: FieldValue.vector(queryEmbedding),
6771
+ queryVector: FieldValue3.vector(queryEmbedding),
5421
6772
  limit: fetchLimit,
5422
6773
  distanceMeasure: "COSINE",
5423
6774
  distanceResultField: "_distance"
@@ -5507,7 +6858,7 @@ async function findNearestInCollectionWithOverride(params) {
5507
6858
  }
5508
6859
 
5509
6860
  // src/tools/marketing/content.ts
5510
- var import_zod15 = require("zod");
6861
+ var import_zod35 = require("zod");
5511
6862
  var _logOverride = null;
5512
6863
  async function logToMcpLogs(entry) {
5513
6864
  if (_logOverride) return _logOverride(entry);
@@ -5520,22 +6871,22 @@ async function logToMcpLogs(entry) {
5520
6871
  } catch {
5521
6872
  }
5522
6873
  }
5523
- var IncludeSchema = import_zod15.z.object({
5524
- products: import_zod15.z.boolean().default(true),
5525
- collections: import_zod15.z.boolean().default(true),
5526
- articles: import_zod15.z.boolean().default(true),
5527
- pages: import_zod15.z.boolean().default(false)
6874
+ var IncludeSchema = import_zod35.z.object({
6875
+ products: import_zod35.z.boolean().default(true),
6876
+ collections: import_zod35.z.boolean().default(true),
6877
+ articles: import_zod35.z.boolean().default(true),
6878
+ pages: import_zod35.z.boolean().default(false)
5528
6879
  }).default({
5529
6880
  products: true,
5530
6881
  collections: true,
5531
6882
  articles: true,
5532
6883
  pages: false
5533
6884
  });
5534
- var LimitSchema = import_zod15.z.object({
5535
- products: import_zod15.z.number().int().min(0).max(20).default(5),
5536
- collections: import_zod15.z.number().int().min(0).max(10).default(3),
5537
- articles: import_zod15.z.number().int().min(0).max(20).default(5),
5538
- pages: import_zod15.z.number().int().min(0).max(10).default(2)
6885
+ var LimitSchema = import_zod35.z.object({
6886
+ products: import_zod35.z.number().int().min(0).max(20).default(5),
6887
+ collections: import_zod35.z.number().int().min(0).max(10).default(3),
6888
+ articles: import_zod35.z.number().int().min(0).max(20).default(5),
6889
+ pages: import_zod35.z.number().int().min(0).max(10).default(2)
5539
6890
  }).default({ products: 5, collections: 3, articles: 5, pages: 2 });
5540
6891
  async function findContentForTopicHandler(input, session) {
5541
6892
  const tenantId = session.requireTenant();
@@ -5621,13 +6972,13 @@ MODOS (parametro mode):
5621
6972
 
5622
6973
  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.`,
5623
6974
  {
5624
- brandId: import_zod15.z.string().optional().describe("ID de la brand"),
5625
- contexto: import_zod15.z.string().min(1).describe("Parrafo, keyword o intencion"),
5626
- fecha: import_zod15.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
6975
+ brandId: import_zod35.z.string().optional().describe("ID de la brand"),
6976
+ contexto: import_zod35.z.string().min(1).describe("Parrafo, keyword o intencion"),
6977
+ fecha: import_zod35.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
5627
6978
  include: IncludeSchema.optional(),
5628
6979
  limit: LimitSchema.optional(),
5629
- diversidad: import_zod15.z.boolean().default(true),
5630
- 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)")
6980
+ diversidad: import_zod35.z.boolean().default(true),
6981
+ 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)")
5631
6982
  },
5632
6983
  async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode: mode2 }) => {
5633
6984
  const brandId = inputBrandId ?? session.requireBrand();
@@ -5731,11 +7082,11 @@ REGLAS:
5731
7082
 
5732
7083
  USAR: antes de generar contenido de cualquier slot del calendario.`,
5733
7084
  {
5734
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
5735
- keyword: import_zod16.z.string().describe("Keyword del slot"),
5736
- plataforma: import_zod16.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
5737
- fecha: import_zod16.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
5738
- limit: import_zod16.z.number().int().min(1).max(10).default(5)
7085
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7086
+ keyword: import_zod36.z.string().describe("Keyword del slot"),
7087
+ plataforma: import_zod36.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
7088
+ fecha: import_zod36.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
7089
+ limit: import_zod36.z.number().int().min(1).max(10).default(5)
5739
7090
  },
5740
7091
  async ({ brandId: inputBrandId, keyword, plataforma, fecha, limit }) => {
5741
7092
  const tenantId = session.requireTenant();
@@ -5776,15 +7127,17 @@ DESPUES de ver la foto, decide:
5776
7127
 
5777
7128
  Luego llama execute_photo_edit con tu analisis y prompt.`,
5778
7129
  {
5779
- fotoId: import_zod16.z.string().describe("ID de la foto")
7130
+ fotoId: import_zod36.z.string().describe("ID de la foto")
5780
7131
  },
5781
7132
  async ({ fotoId }) => {
5782
7133
  const tenantId = session.requireTenant();
7134
+ const lang = await resolveTenantIdioma(tenantId);
5783
7135
  const result = getMode() === "admin" ? await photoDirectorPlan({
5784
7136
  db: getAdminDb(),
5785
7137
  tenantId,
5786
7138
  fotoId,
5787
- deps: { compressImageForTransport }
7139
+ deps: { compressImageForTransport },
7140
+ lang
5788
7141
  }) : await callPhotoDirectorPlan({ tenantId, fotoId });
5789
7142
  if (!result.ok) {
5790
7143
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -5807,16 +7160,18 @@ Retorna la foto editada para que la revises. Si no te gusta:
5807
7160
 
5808
7161
  Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si se genera thumbnail y embedding con tus tags.`,
5809
7162
  {
5810
- fotoId: import_zod16.z.string(),
5811
- prompt: import_zod16.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
5812
- acciones: import_zod16.z.array(import_zod16.z.enum(["edit_background", "none"])),
5813
- descripcion: import_zod16.z.string().describe("Descripcion semantica en espanol"),
5814
- tipo: import_zod16.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
5815
- tagsPrimarios: import_zod16.z.array(import_zod16.z.string()),
5816
- tagsSecundarios: import_zod16.z.array(import_zod16.z.string()),
5817
- tagsContexto: import_zod16.z.array(import_zod16.z.string())
7163
+ fotoId: import_zod36.z.string(),
7164
+ prompt: import_zod36.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
7165
+ acciones: import_zod36.z.array(import_zod36.z.enum(["edit_background", "none"])),
7166
+ descripcion: import_zod36.z.string().describe("Descripcion semantica en espanol"),
7167
+ tipo: import_zod36.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
7168
+ tagsPrimarios: import_zod36.z.array(import_zod36.z.string()),
7169
+ tagsSecundarios: import_zod36.z.array(import_zod36.z.string()),
7170
+ tagsContexto: import_zod36.z.array(import_zod36.z.string())
5818
7171
  },
5819
7172
  async ({ fotoId, prompt, acciones, descripcion, tipo, tagsPrimarios, tagsSecundarios, tagsContexto }) => {
7173
+ const tenantId = session.requireTenant();
7174
+ const lang = await resolveTenantIdioma(tenantId);
5820
7175
  if (getMode() === "admin") {
5821
7176
  const executePhotoEditAdapter = async (payload) => {
5822
7177
  const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
@@ -5840,7 +7195,8 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
5840
7195
  tagsPrimarios,
5841
7196
  tagsSecundarios,
5842
7197
  tagsContexto,
5843
- deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter }
7198
+ deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter },
7199
+ lang
5844
7200
  });
5845
7201
  if (!result2.ok) {
5846
7202
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
@@ -5854,7 +7210,7 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
5854
7210
  return { content: contentArray2 };
5855
7211
  }
5856
7212
  const result = await callPhotoDirectorExecute({
5857
- tenantId: session.requireTenant(),
7213
+ tenantId,
5858
7214
  fotoId,
5859
7215
  prompt,
5860
7216
  acciones,
@@ -5888,7 +7244,7 @@ Retorna:
5888
7244
  - creditos: { balance, planId, periodEnd, costoPhotoEdit }
5889
7245
  - _instrucciones: que hacer segun el estado`,
5890
7246
  {
5891
- fotoId: import_zod16.z.string().describe("ID de la foto")
7247
+ fotoId: import_zod36.z.string().describe("ID de la foto")
5892
7248
  },
5893
7249
  async ({ fotoId }) => {
5894
7250
  const tenantId = session.requireTenant();
@@ -5931,7 +7287,7 @@ Retorna:
5931
7287
  };
5932
7288
  }
5933
7289
  }
5934
- const creditsDoc = await readDoc("brand_credits", `${tenantId}_${brandId}`);
7290
+ const creditsDoc = await readDoc(`tenants/${tenantId}/brand_credits`, brandId);
5935
7291
  const balance = creditsDoc?.balance ?? 0;
5936
7292
  const planId = creditsDoc?.planId ?? null;
5937
7293
  const periodEnd = creditsDoc?.periodEnd ?? null;
@@ -5988,11 +7344,11 @@ Retorna:
5988
7344
  "find_products_for_content",
5989
7345
  `[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.`,
5990
7346
  {
5991
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
5992
- contexto: import_zod16.z.string().describe("Parrafo, keyword o intencion del contenido"),
5993
- fecha: import_zod16.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
5994
- limit: import_zod16.z.number().int().min(1).max(10).default(5),
5995
- diversidad: import_zod16.z.boolean().default(true)
7347
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7348
+ contexto: import_zod36.z.string().describe("Parrafo, keyword o intencion del contenido"),
7349
+ fecha: import_zod36.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7350
+ limit: import_zod36.z.number().int().min(1).max(10).default(5),
7351
+ diversidad: import_zod36.z.boolean().default(true)
5996
7352
  },
5997
7353
  async ({ brandId: inputBrandId, contexto, fecha, limit, diversidad }) => {
5998
7354
  console.warn(
@@ -6043,12 +7399,12 @@ Retorna:
6043
7399
 
6044
7400
  RETORNA { plantillaId, titulo, thumbnailUrl } o { plantillaId: null, motivo: 'no_canva' } si tenant no tiene Canva conectado.
6045
7401
 
6046
- USAR: solo si tenant tiene Canva conectado (marketing_config.brands[brandId].canva.connected=true).`,
7402
+ USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/{brandId}.canva.connected=true).`,
6047
7403
  {
6048
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
6049
- plataforma: import_zod16.z.string().describe("gbp | instagram | shopify_blog"),
6050
- tipoContenido: import_zod16.z.string().describe("post | carousel | story | blog"),
6051
- keyword: import_zod16.z.string().describe("Keyword del slot")
7404
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7405
+ plataforma: import_zod36.z.string().describe("gbp | instagram | shopify_blog"),
7406
+ tipoContenido: import_zod36.z.string().describe("post | carousel | story | blog"),
7407
+ keyword: import_zod36.z.string().describe("Keyword del slot")
6052
7408
  },
6053
7409
  async ({ brandId: inputBrandId, plataforma, tipoContenido, keyword }) => {
6054
7410
  const tenantId = session.requireTenant();
@@ -6078,15 +7434,15 @@ USAR: cuando get_photos_for_slot retorna pocas fotos y el slot aun no esta cubie
6078
7434
 
6079
7435
  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.`,
6080
7436
  {
6081
- brandId: import_zod16.z.string().optional().describe("ID de la brand"),
6082
- 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"),
6083
- necesidades: import_zod16.z.array(
6084
- import_zod16.z.object({
6085
- tema: import_zod16.z.string(),
6086
- keyword: import_zod16.z.string(),
6087
- cantidadSugerida: import_zod16.z.number().int().positive(),
6088
- razon: import_zod16.z.string(),
6089
- slotsAfectados: import_zod16.z.array(import_zod16.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
7437
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7438
+ 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"),
7439
+ necesidades: import_zod36.z.array(
7440
+ import_zod36.z.object({
7441
+ tema: import_zod36.z.string(),
7442
+ keyword: import_zod36.z.string(),
7443
+ cantidadSugerida: import_zod36.z.number().int().positive(),
7444
+ razon: import_zod36.z.string(),
7445
+ slotsAfectados: import_zod36.z.array(import_zod36.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
6090
7446
  })
6091
7447
  ).min(1)
6092
7448
  },
@@ -6225,16 +7581,6 @@ async function linkContenidoAPasoPipeline(params) {
6225
7581
  }
6226
7582
 
6227
7583
  // src/tools/marketing.ts
6228
- async function resolveLastImportId3(tenantId, brandId) {
6229
- const config = await readDoc("marketing_config", tenantId);
6230
- const brands = config?.brands ?? {};
6231
- const brand = brands[brandId];
6232
- const canalId = brand?.canalId;
6233
- if (!canalId) return null;
6234
- const canal = await readDoc("canales_venta", canalId);
6235
- const app = canal?.app ?? {};
6236
- return app.lastImportId ?? null;
6237
- }
6238
7584
  function registerMarketingTools(server, session) {
6239
7585
  registerPhotoTools(server, session);
6240
7586
  registerContentTools(server, session);
@@ -6242,48 +7588,46 @@ function registerMarketingTools(server, session) {
6242
7588
  "get_calendar",
6243
7589
  "Lee el calendario editorial del mes. Muestra semanas con items planificados por plataforma.",
6244
7590
  {
6245
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6246
- mes: import_zod17.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
7591
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7592
+ mes: import_zod37.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
6247
7593
  },
6248
7594
  async ({ brandId: inputBrandId, mes }) => {
6249
- session.requireTenant();
7595
+ const tenantId = session.requireTenant();
6250
7596
  const brandId = inputBrandId ?? session.requireBrand();
6251
7597
  const targetMes = mes ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
6252
- const calendarios = await queryByTenant(session, "marketing_calendario", [
6253
- { field: "brandId", op: "==", value: brandId },
6254
- { field: "mes", op: "==", value: targetMes }
6255
- ], { limit: 1 });
6256
- if (calendarios.length === 0) {
6257
- return {
6258
- content: [{
6259
- type: "text",
6260
- text: JSON.stringify({
6261
- mes: targetMes,
6262
- brandId,
6263
- semanas: [],
6264
- mensaje: "No hay calendario para este mes. Genera un plan de marketing primero."
6265
- })
6266
- }]
7598
+ const ctx = buildMartinContext(session, brandId);
7599
+ const result = await dispatchWithContract({
7600
+ contract: getCalendarContract,
7601
+ helper: getCalendar,
7602
+ callable: callGetCalendar,
7603
+ input: { tenantId, brandId, mes: targetMes },
7604
+ ctx
7605
+ });
7606
+ let payload;
7607
+ if (result.state === "success" && result.structuredOutput) {
7608
+ const out = result.structuredOutput;
7609
+ payload = out.calendario ?? {
7610
+ mes: out.mes,
7611
+ brandId: out.brandId,
7612
+ semanas: [],
7613
+ mensaje: out.mensaje
6267
7614
  };
7615
+ } else {
7616
+ payload = { ok: false, state: result.state, mensaje: result.text };
6268
7617
  }
6269
- return { content: [{ type: "text", text: JSON.stringify(calendarios[0], null, 2) }] };
7618
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
6270
7619
  }
6271
7620
  );
6272
7621
  server.tool(
6273
7622
  "get_seo_snapshot",
6274
7623
  "Lee el snapshot SEO de la brand: rank, keywords, oportunidades, competidores.",
6275
7624
  {
6276
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7625
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6277
7626
  },
6278
7627
  async ({ brandId: inputBrandId }) => {
6279
7628
  const tenantId = session.requireTenant();
6280
7629
  const brandId = inputBrandId ?? session.requireBrand();
6281
- const config = await readDoc("marketing_config", tenantId);
6282
- if (!config) {
6283
- return { content: [{ type: "text", text: JSON.stringify({ error: "No hay marketing_config" }) }] };
6284
- }
6285
- const brands = config.brands ?? {};
6286
- const brand = brands[brandId];
7630
+ const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
6287
7631
  if (!brand) {
6288
7632
  return { content: [{ type: "text", text: JSON.stringify({ error: `Brand "${brandId}" no encontrada` }) }] };
6289
7633
  }
@@ -6297,8 +7641,8 @@ function registerMarketingTools(server, session) {
6297
7641
  "get_photo_gallery",
6298
7642
  "Fotos disponibles filtradas por estado. Muestra fotos con metadata y conteo.",
6299
7643
  {
6300
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6301
- estado: import_zod17.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
7644
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7645
+ estado: import_zod37.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
6302
7646
  },
6303
7647
  async ({ brandId: inputBrandId, estado }) => {
6304
7648
  session.requireTenant();
@@ -6338,7 +7682,7 @@ function registerMarketingTools(server, session) {
6338
7682
  "generate_marketing_plan",
6339
7683
  "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.",
6340
7684
  {
6341
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7685
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6342
7686
  },
6343
7687
  async ({ brandId: inputBrandId }) => {
6344
7688
  const tenantId = session.requireTenant();
@@ -6350,25 +7694,33 @@ function registerMarketingTools(server, session) {
6350
7694
  );
6351
7695
  server.tool(
6352
7696
  "save_marketing_plan",
6353
- "Guarda el plan de marketing generado por Claude en la configuracion de la brand. Escribe en marketing_config.brands[brandId].plan.",
7697
+ "Guarda el plan de marketing generado por Claude en la configuracion de la brand. Escribe en tenants/{tenantId}/marketing_config/{brandId}.plan.",
6354
7698
  {
6355
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6356
- plan: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).describe("Plan de marketing generado (keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, etc.)")
7699
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7700
+ plan: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Plan de marketing generado (keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, etc.)")
6357
7701
  },
6358
7702
  async ({ brandId: inputBrandId, plan }) => {
6359
7703
  const tenantId = session.requireTenant();
6360
7704
  const brandId = inputBrandId ?? session.requireBrand();
6361
- const result = getMode() === "admin" ? await planWriter.save({ db: getAdminDb(), tenantId, brandId, plan }) : await callPlanWriterSave({ tenantId, brandId, plan });
6362
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
7705
+ const ctx = buildMartinContext(session, brandId);
7706
+ const result = await dispatchWithContract({
7707
+ contract: planWriterSaveContract,
7708
+ helper: planWriter.save,
7709
+ callable: callPlanWriterSave,
7710
+ input: { tenantId, brandId, plan },
7711
+ ctx
7712
+ });
7713
+ const payload = result.state === "success" ? result.structuredOutput : { ok: false, state: result.state, mensaje: result.text };
7714
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6363
7715
  }
6364
7716
  );
6365
7717
  server.tool(
6366
7718
  "update_marketing_plan_field",
6367
7719
  "Actualiza UN campo del plan de marketing sin sobreescribir el resto. Merge parcial. Ideal para agregar coleccionesPriorizadas, actualizar quickWins, etc.",
6368
7720
  {
6369
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6370
- field: import_zod17.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
6371
- value: import_zod17.z.unknown().describe("Valor del campo")
7721
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7722
+ field: import_zod37.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
7723
+ value: import_zod37.z.unknown().describe("Valor del campo")
6372
7724
  },
6373
7725
  async ({ brandId: inputBrandId, field, value }) => {
6374
7726
  const tenantId = session.requireTenant();
@@ -6381,22 +7733,36 @@ function registerMarketingTools(server, session) {
6381
7733
  "generate_brand_brief",
6382
7734
  "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.",
6383
7735
  {
6384
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7736
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6385
7737
  },
6386
7738
  async ({ brandId: inputBrandId }) => {
6387
7739
  const tenantId = session.requireTenant();
6388
7740
  const brandId = inputBrandId ?? session.requireBrand();
6389
- const result = getMode() === "admin" ? await brandBriefBuilder({ db: getAdminDb(), tenantId, brandId }) : await callBrandBriefBuilder({ tenantId, brandId });
6390
- const payload = result.ok ? result.payload : result;
6391
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7741
+ const ctx = buildMartinContext(session, brandId);
7742
+ const result = await dispatchWithContract({
7743
+ contract: brandBriefBuilderContract,
7744
+ helper: brandBriefBuilder,
7745
+ callable: callBrandBriefBuilder,
7746
+ input: { tenantId, brandId },
7747
+ ctx
7748
+ });
7749
+ let returnPayload;
7750
+ if (result.state === "success" && result.structuredOutput?.ok === true) {
7751
+ returnPayload = result.structuredOutput.payload;
7752
+ } else if (result.state === "success") {
7753
+ returnPayload = result.structuredOutput;
7754
+ } else {
7755
+ returnPayload = { ok: false, state: result.state, mensaje: result.text };
7756
+ }
7757
+ return { content: [{ type: "text", text: JSON.stringify(returnPayload, null, 2) }] };
6392
7758
  }
6393
7759
  );
6394
7760
  server.tool(
6395
7761
  "save_brand_brief",
6396
- "Guarda el Brand Brief generado por Claude en marketing_config.brands[brandId].brandBrief. Escribe solo ese campo (merge parcial, no rebuild del doc).",
7762
+ "Guarda el Brand Brief generado por Claude en tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Escribe solo ese campo (merge parcial, no rebuild del doc).",
6397
7763
  {
6398
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6399
- brandBrief: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).describe("Brand Brief completo generado por Claude")
7764
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7765
+ brandBrief: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Brand Brief completo generado por Claude")
6400
7766
  },
6401
7767
  async ({ brandId: inputBrandId, brandBrief }) => {
6402
7768
  const tenantId = session.requireTenant();
@@ -6409,9 +7775,9 @@ function registerMarketingTools(server, session) {
6409
7775
  "generate_weekly_content",
6410
7776
  "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.",
6411
7777
  {
6412
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6413
- semana: import_zod17.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
6414
- 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'.")
7778
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7779
+ semana: import_zod37.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
7780
+ 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'.")
6415
7781
  },
6416
7782
  async ({ brandId: inputBrandId, semana, modo }) => {
6417
7783
  const tenantId = session.requireTenant();
@@ -6427,14 +7793,14 @@ function registerMarketingTools(server, session) {
6427
7793
 
6428
7794
  IMPORTANTE: Si seleccionaste una foto con get_photos_for_slot, SIEMPRE pasa fotoId aqu\xED. Sin fotoId el post se publica sin imagen.`,
6429
7795
  {
6430
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6431
- plataforma: import_zod17.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
6432
- tipo: import_zod17.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
6433
- keyword: import_zod17.z.string().optional().describe("Keyword target"),
6434
- languageCode: import_zod17.z.string().optional().describe("Idioma (es/en)"),
6435
- fotoId: import_zod17.z.string().optional().describe("ID de la foto a asociar"),
6436
- datos: import_zod17.z.record(import_zod17.z.string(), import_zod17.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
6437
- calendarioItemRef: import_zod17.z.string().optional().describe("Referencia al item del calendario")
7796
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7797
+ plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
7798
+ tipo: import_zod37.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
7799
+ keyword: import_zod37.z.string().optional().describe("Keyword target"),
7800
+ languageCode: import_zod37.z.string().optional().describe("Idioma (es/en)"),
7801
+ fotoId: import_zod37.z.string().optional().describe("ID de la foto a asociar"),
7802
+ datos: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
7803
+ calendarioItemRef: import_zod37.z.string().optional().describe("Referencia al item del calendario")
6438
7804
  },
6439
7805
  async ({ brandId: inputBrandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef }) => {
6440
7806
  const tenantId = session.requireTenant();
@@ -6479,13 +7845,13 @@ Usa para: corregir body, metaTitle, tags, fotoId, o cualquier campo sin tener qu
6479
7845
  NO puede cambiar: tenantId, brandId, id (inmutables).
6480
7846
  Si pasas campos dentro de "datos", se hace merge con los datos existentes (no los reemplaza entero).`,
6481
7847
  {
6482
- contenidoId: import_zod17.z.string().describe("ID del doc en marketing_contenido"),
6483
- 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: "..." }'),
6484
- fotoId: import_zod17.z.string().nullable().optional().describe("Actualizar foto asociada"),
6485
- keyword: import_zod17.z.string().nullable().optional().describe("Actualizar keyword"),
6486
- languageCode: import_zod17.z.string().optional().describe("Actualizar idioma"),
6487
- estado: import_zod17.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
6488
- calendarioItemRef: import_zod17.z.string().nullable().optional().describe("Vincular a un slot del calendario")
7848
+ contenidoId: import_zod37.z.string().describe("ID del doc en marketing_contenido"),
7849
+ 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: "..." }'),
7850
+ fotoId: import_zod37.z.string().nullable().optional().describe("Actualizar foto asociada"),
7851
+ keyword: import_zod37.z.string().nullable().optional().describe("Actualizar keyword"),
7852
+ languageCode: import_zod37.z.string().optional().describe("Actualizar idioma"),
7853
+ estado: import_zod37.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
7854
+ calendarioItemRef: import_zod37.z.string().nullable().optional().describe("Vincular a un slot del calendario")
6489
7855
  },
6490
7856
  async ({ contenidoId, datos: newDatos, fotoId, keyword, languageCode, estado, calendarioItemRef }) => {
6491
7857
  const tenantId = session.requireTenant();
@@ -6516,27 +7882,27 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
6516
7882
  "update_calendar_slot",
6517
7883
  "Modifica o agrega un slot del calendario editorial. Si slotIndex >= items.length, agrega un slot nuevo.",
6518
7884
  {
6519
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
6520
- mes: import_zod17.z.string().describe("Mes del calendario (YYYY-MM)"),
6521
- semana: import_zod17.z.number().describe("Numero de semana (1-5)"),
6522
- slotIndex: import_zod17.z.number().describe("Indice del slot (0-based). Si >= items.length, agrega nuevo slot al final."),
6523
- cambios: import_zod17.z.object({
6524
- dia: import_zod17.z.string().nullable().optional(),
6525
- plataforma: import_zod17.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional(),
6526
- tipo: import_zod17.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional(),
6527
- keyword: import_zod17.z.string().nullable().optional(),
6528
- tema: import_zod17.z.string().nullable().optional(),
6529
- productoId: import_zod17.z.string().nullable().optional(),
6530
- estado: import_zod17.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
6531
- contenidoRef: import_zod17.z.string().nullable().optional(),
6532
- fotoIdAsignada: import_zod17.z.string().nullable().optional(),
6533
- notas: import_zod17.z.array(NotaCalendarioSchema).optional(),
6534
- locationId: import_zod17.z.string().nullable().optional().describe("GBP location ID \u2014 a qu\xE9 sucursal publicar el post"),
6535
- locationNombre: import_zod17.z.string().nullable().optional().describe("Nombre de la sucursal GBP (para UI)")
7885
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7886
+ mes: import_zod37.z.string().describe("Mes del calendario (YYYY-MM)"),
7887
+ semana: import_zod37.z.number().describe("Numero de semana (1-5)"),
7888
+ slotIndex: import_zod37.z.number().describe("Indice del slot (0-based). Si >= items.length, agrega nuevo slot al final."),
7889
+ cambios: import_zod37.z.object({
7890
+ dia: import_zod37.z.string().nullable().optional(),
7891
+ plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional(),
7892
+ tipo: import_zod37.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional(),
7893
+ keyword: import_zod37.z.string().nullable().optional(),
7894
+ tema: import_zod37.z.string().nullable().optional(),
7895
+ productoId: import_zod37.z.string().nullable().optional(),
7896
+ estado: import_zod37.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
7897
+ contenidoRef: import_zod37.z.string().nullable().optional(),
7898
+ fotoIdAsignada: import_zod37.z.string().nullable().optional(),
7899
+ notas: import_zod37.z.array(NotaCalendarioSchema).optional(),
7900
+ locationId: import_zod37.z.string().nullable().optional().describe("GBP location ID \u2014 a qu\xE9 sucursal publicar el post"),
7901
+ locationNombre: import_zod37.z.string().nullable().optional().describe("Nombre de la sucursal GBP (para UI)")
6536
7902
  }).strict().describe("Campos del slot. Solo acepta campos validos del schema. Campos no reconocidos seran rechazados."),
6537
- accionContenidoExistente: import_zod17.z.union([
6538
- import_zod17.z.enum(["descartar", "nuevo_slot", "mantener"]),
6539
- import_zod17.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Formato: mover:semana:N:slot:M")
7903
+ accionContenidoExistente: import_zod37.z.union([
7904
+ import_zod37.z.enum(["descartar", "nuevo_slot", "mantener"]),
7905
+ import_zod37.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Formato: mover:semana:N:slot:M")
6540
7906
  ]).optional().describe(
6541
7907
  "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)."
6542
7908
  )
@@ -6544,9 +7910,16 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
6544
7910
  async ({ brandId: inputBrandId, mes, semana, slotIndex, cambios, accionContenidoExistente }) => {
6545
7911
  const tenantId = session.requireTenant();
6546
7912
  const brandId = inputBrandId ?? session.requireBrand();
6547
- const accionTyped = accionContenidoExistente;
6548
- 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 });
6549
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
7913
+ const ctx = buildMartinContext(session, brandId);
7914
+ const result = await dispatchWithContract({
7915
+ contract: calendarSlotUpdaterContract,
7916
+ helper: calendarSlotUpdater,
7917
+ callable: callCalendarSlotUpdater,
7918
+ input: { tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente },
7919
+ ctx
7920
+ });
7921
+ const payload = result.state === "success" ? result.structuredOutput : { ok: false, state: result.state, mensaje: result.text };
7922
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6550
7923
  }
6551
7924
  );
6552
7925
  server.tool(
@@ -6559,9 +7932,9 @@ ESCRIBE EN DOS LUGARES:
6559
7932
 
6560
7933
  NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agenda, no en el post.`,
6561
7934
  {
6562
- contenidoRef: import_zod17.z.string().describe("ID del doc en marketing_contenido"),
6563
- fotoId: import_zod17.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
6564
- calendarioItemRef: import_zod17.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
7935
+ contenidoRef: import_zod37.z.string().describe("ID del doc en marketing_contenido"),
7936
+ fotoId: import_zod37.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
7937
+ calendarioItemRef: import_zod37.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
6565
7938
  },
6566
7939
  async ({ contenidoRef, fotoId, calendarioItemRef }) => {
6567
7940
  const tenantId = session.requireTenant();
@@ -6574,7 +7947,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6574
7947
  "approve_content",
6575
7948
  "Aprueba contenido para publicacion. Valida transicion de estado.",
6576
7949
  {
6577
- contenidoId: import_zod17.z.string().describe("ID del contenido a aprobar")
7950
+ contenidoId: import_zod37.z.string().describe("ID del contenido a aprobar")
6578
7951
  },
6579
7952
  async ({ contenidoId }) => {
6580
7953
  session.requireTenant();
@@ -6598,8 +7971,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6598
7971
  "reject_content",
6599
7972
  "Rechaza contenido con motivo. Valida transicion de estado.",
6600
7973
  {
6601
- contenidoId: import_zod17.z.string().describe("ID del contenido a rechazar"),
6602
- motivo: import_zod17.z.string().describe("Motivo del rechazo")
7974
+ contenidoId: import_zod37.z.string().describe("ID del contenido a rechazar"),
7975
+ motivo: import_zod37.z.string().describe("Motivo del rechazo")
6603
7976
  },
6604
7977
  async ({ contenidoId, motivo }) => {
6605
7978
  session.requireTenant();
@@ -6619,44 +7992,44 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6619
7992
  }
6620
7993
  );
6621
7994
  server.tool(
6622
- "get_shopify_collections",
6623
- "Lee todas las colecciones de Shopify con sus 6 campos SEO actuales. Incluye best practices 2026.",
7995
+ "get_collections",
7996
+ "Lee todas las colecciones canonicas (Shopify/WordPress/webonly) con sus 6 campos SEO actuales. Incluye best practices 2026.",
6624
7997
  {
6625
- brandId: import_zod17.z.string().optional().describe("ID de la brand")
7998
+ brandId: import_zod37.z.string().optional().describe("ID de la brand")
6626
7999
  },
6627
8000
  async ({ brandId: inputBrandId }) => {
6628
8001
  const tenantId = session.requireTenant();
6629
8002
  const brandId = inputBrandId ?? session.requireBrand();
6630
- const lastImportId = await resolveLastImportId3(tenantId, brandId);
6631
- if (!lastImportId) {
8003
+ const db = getAdminDb();
8004
+ const collSnap = await db.collection(`tenants/${tenantId}/marketing_snapshots/${brandId}/collections`).get();
8005
+ const items = collSnap.docs.map((d) => d.data());
8006
+ if (items.length === 0) {
6632
8007
  return { content: [{ type: "text", text: JSON.stringify({
6633
8008
  ok: false,
6634
- error: "No hay import de Shopify. El tenant debe sincronizar desde Marketing > Mi Plan > Shopify."
8009
+ error: "No hay colecciones nested. El tenant debe sincronizar desde Marketing > Configuraci\xF3n > Shopify > Sincronizar."
6635
8010
  }) }] };
6636
8011
  }
6637
- const collectionsDoc = await readDoc(
6638
- `tenants/${tenantId}/shopify_imports/${lastImportId}/data`,
6639
- "collections"
6640
- );
6641
- const items = collectionsDoc?.items ?? [];
6642
- const config = await readDoc("marketing_config", tenantId);
6643
- const brands = config?.brands ?? {};
6644
- const brand = brands[brandId];
8012
+ const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
6645
8013
  const plan = brand?.plan ?? {};
6646
8014
  const keywords = plan.keywordsPrioritarios ?? [];
6647
8015
  const existingSuggestions = brand?.collectionSuggestions ?? {};
6648
- const collections = items.map((c) => ({
6649
- id: c.id,
6650
- title: c.title,
6651
- handle: c.handle,
6652
- body_html: typeof c.body_html === "string" ? c.body_html.slice(0, 500) : null,
6653
- metaTitle: c.metaTitle ?? null,
6654
- metaDescription: c.metaDescription ?? null,
6655
- products_count: c.products_count ?? null,
6656
- collectionType: c.collectionType ?? null,
6657
- image: c.image ? { src: c.image.src, alt: c.image.alt } : null,
6658
- existingSuggestion: existingSuggestions[String(c.id)] ?? null
6659
- }));
8016
+ const collections = items.map((c) => {
8017
+ const seo = c.seo || {};
8018
+ const featured = c.featuredImage || null;
8019
+ return {
8020
+ id: c.platformId,
8021
+ title: c.title,
8022
+ handle: c.handle,
8023
+ body_html: typeof c.descriptionHtml === "string" ? c.descriptionHtml.slice(0, 500) : null,
8024
+ metaTitle: seo.metaTitle ?? null,
8025
+ metaDescription: seo.metaDescription ?? null,
8026
+ products_count: null,
8027
+ // adapter v2 aun no calcula products_count por collection
8028
+ collectionType: "canonical",
8029
+ image: featured ? { src: featured.url, alt: featured.altText } : null,
8030
+ existingSuggestion: existingSuggestions[String(c.platformId)] ?? null
8031
+ };
8032
+ });
6660
8033
  return { content: [{ type: "text", text: JSON.stringify({
6661
8034
  ok: true,
6662
8035
  totalCollections: collections.length,
@@ -6709,7 +8082,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
6709
8082
  "save_collection_suggestions",
6710
8083
  "Guarda sugerencias SEO para colecciones de Shopify. El tenant las aprueba en la UI.",
6711
8084
  {
6712
- brandId: import_zod17.z.string().optional().describe("ID de la brand"),
8085
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
6713
8086
  suggestions: CollectionSuggestionsInputArraySchema.describe("Array de sugerencias SEO. Cada una con max 60 chars en metaTitle, max 158 en metaDescription, max 125 en imageAlt")
6714
8087
  },
6715
8088
  async ({ brandId: inputBrandId, suggestions }) => {