@badgie/crm-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,135 +1,1426 @@
1
1
  import { getAuthedClient } from '../core/supabase.js';
2
2
  import { commonListOptions, output, parseColumns, parseLimit } from '../core/format.js';
3
+ import { maybeResolve } from '../core/resolve.js';
4
+ // ─── Enums (replicated from lib/crm/marketing-planner-types.ts) ────────────
5
+ const POST_STATUSES = [
6
+ 'idea',
7
+ 'draft',
8
+ 'needs_review',
9
+ 'approved',
10
+ 'scheduled',
11
+ 'published',
12
+ 'failed',
13
+ 'manual_required',
14
+ 'cancelled',
15
+ ];
16
+ const CAMPAIGN_STATUSES = [
17
+ 'draft',
18
+ 'scheduled',
19
+ 'active',
20
+ 'paused',
21
+ 'completed',
22
+ 'cancelled',
23
+ ];
24
+ const PUBLICATION_STATUSES = [
25
+ 'pending',
26
+ 'scheduled',
27
+ 'published',
28
+ 'failed',
29
+ 'manual_required',
30
+ 'cancelled',
31
+ ];
32
+ const REVIEW_STATUSES = ['requested', 'approved', 'changes_requested', 'rejected'];
33
+ const CHANNEL_TYPES = ['social', 'blog', 'email', 'messaging', 'video'];
34
+ const PUBLISHING_METHODS = ['manual', 'zapier', 'api', 'native', 'not_supported_yet'];
35
+ const ASSET_TYPES = ['image', 'video', 'thumbnail', 'audio', 'document', 'other'];
36
+ const ASSET_ROLES = ['primary', 'secondary', 'thumbnail', 'source', 'final'];
37
+ const METRIC_SOURCES = ['manual', 'api', 'zapier'];
38
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
39
+ function ensureEnum(allowed, value, label) {
40
+ if (!allowed.includes(value)) {
41
+ throw new Error(`Invalid ${label} "${value}". Allowed: ${allowed.join(', ')}`);
42
+ }
43
+ return value;
44
+ }
45
+ function slugify(input) {
46
+ return input
47
+ .toLowerCase()
48
+ .normalize('NFD')
49
+ .replace(/[̀-ͯ]/g, '')
50
+ .replace(/[^a-z0-9]+/g, '-')
51
+ .replace(/(^-|-$)+/g, '');
52
+ }
53
+ // ─── Resolution helpers (id ↔ slug ↔ name) ─────────────────────────────────
54
+ async function resolveCampaign(client, idOrSlug) {
55
+ if (UUID_RE.test(idOrSlug))
56
+ return idOrSlug;
57
+ const { data, error } = await client
58
+ .from('marketing_campaigns')
59
+ .select('id, slug, name')
60
+ .or(`slug.eq.${idOrSlug},name.ilike.%${idOrSlug}%`)
61
+ .limit(2);
62
+ if (error)
63
+ throw error;
64
+ const rows = (data ?? []);
65
+ if (rows.length === 0) {
66
+ throw new Error(`No campaign matches "${idOrSlug}". Try: badgie-crm marketing campaigns list --pretty`);
67
+ }
68
+ if (rows.length > 1) {
69
+ const names = rows.map((r) => `${r.slug} (${r.name})`).join(', ');
70
+ throw new Error(`Ambiguous campaign "${idOrSlug}": ${names}. Use slug or UUID.`);
71
+ }
72
+ return rows[0].id;
73
+ }
74
+ async function resolveChannel(client, idOrSlug) {
75
+ if (UUID_RE.test(idOrSlug))
76
+ return idOrSlug;
77
+ const { data, error } = await client
78
+ .from('marketing_channels')
79
+ .select('id, slug')
80
+ .eq('slug', idOrSlug)
81
+ .maybeSingle();
82
+ if (error)
83
+ throw error;
84
+ if (!data) {
85
+ throw new Error(`No channel matches "${idOrSlug}". Try: badgie-crm marketing channels list --pretty`);
86
+ }
87
+ return data.id;
88
+ }
89
+ async function resolvePost(client, idOrSlug) {
90
+ if (UUID_RE.test(idOrSlug))
91
+ return idOrSlug;
92
+ const { data, error } = await client
93
+ .from('marketing_posts')
94
+ .select('id, slug')
95
+ .eq('slug', idOrSlug)
96
+ .maybeSingle();
97
+ if (error)
98
+ throw error;
99
+ if (!data) {
100
+ throw new Error(`No post matches "${idOrSlug}". Try: badgie-crm marketing posts list --pretty`);
101
+ }
102
+ return data.id;
103
+ }
104
+ async function getCallerTeamMemberId(client) {
105
+ const { data: userRes } = await client.auth.getUser();
106
+ const email = userRes.user?.email;
107
+ if (!email)
108
+ return null;
109
+ const { data } = await client
110
+ .from('team_members')
111
+ .select('id')
112
+ .eq('email', email)
113
+ .maybeSingle();
114
+ return data?.id ?? null;
115
+ }
116
+ // ─── Planner reads (Coach daily flow) ──────────────────────────────────────
117
+ async function plannerToday(_a, opts) {
118
+ const { client } = await getAuthedClient();
119
+ const start = new Date();
120
+ start.setHours(0, 0, 0, 0);
121
+ const end = new Date(start);
122
+ end.setDate(end.getDate() + 1);
123
+ const { data, error } = await client
124
+ .from('marketing_posts')
125
+ .select(`id, internal_title, slug, status, scheduled_at, copy_main, cta_label, cta_url, hashtags,
126
+ campaign:marketing_campaigns(id, name, slug),
127
+ primary_channel:marketing_channels!marketing_posts_primary_channel_id_fkey(id, name, slug, publishing_method),
128
+ owner:team_members!marketing_posts_owner_id_fkey(id, full_name, email)`)
129
+ .gte('scheduled_at', start.toISOString())
130
+ .lt('scheduled_at', end.toISOString())
131
+ .order('scheduled_at', { ascending: true });
132
+ if (error)
133
+ throw error;
134
+ output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
135
+ }
136
+ async function plannerWeek(_a, opts) {
137
+ const { client } = await getAuthedClient();
138
+ const anchor = typeof opts.week === 'string' ? new Date(`${opts.week}T00:00:00`) : new Date();
139
+ // Lunes 00:00 de la semana del anchor.
140
+ const day = anchor.getDay(); // 0 dom .. 6 sáb
141
+ const offsetToMonday = day === 0 ? -6 : 1 - day;
142
+ const start = new Date(anchor);
143
+ start.setDate(start.getDate() + offsetToMonday);
144
+ start.setHours(0, 0, 0, 0);
145
+ const end = new Date(start);
146
+ end.setDate(end.getDate() + 7);
147
+ const { data, error } = await client
148
+ .from('marketing_posts')
149
+ .select(`id, internal_title, slug, status, scheduled_at, copy_main, hashtags,
150
+ campaign:marketing_campaigns(id, name, slug),
151
+ primary_channel:marketing_channels!marketing_posts_primary_channel_id_fkey(id, name, slug),
152
+ owner:team_members!marketing_posts_owner_id_fkey(id, full_name, email)`)
153
+ .gte('scheduled_at', start.toISOString())
154
+ .lt('scheduled_at', end.toISOString())
155
+ .order('scheduled_at', { ascending: true });
156
+ if (error)
157
+ throw error;
158
+ // Agrupar por día (YYYY-MM-DD).
159
+ const groups = {};
160
+ for (const row of (data ?? [])) {
161
+ const key = row.scheduled_at.slice(0, 10);
162
+ groups[key] ??= [];
163
+ groups[key].push(row);
164
+ }
165
+ output({
166
+ week_start: start.toISOString().slice(0, 10),
167
+ week_end: end.toISOString().slice(0, 10),
168
+ posts_by_day: groups,
169
+ total_posts: (data ?? []).length,
170
+ }, { pretty: !!opts.pretty });
171
+ }
172
+ async function plannerBlockers(_a, opts) {
173
+ const { client } = await getAuthedClient();
174
+ const { data, error } = await client
175
+ .from('marketing_posts')
176
+ .select(`id, internal_title, slug, status, scheduled_at, copy_main, primary_channel_id, error_message,
177
+ campaign:marketing_campaigns(name, slug),
178
+ primary_channel:marketing_channels!marketing_posts_primary_channel_id_fkey(name, slug),
179
+ owner:team_members!marketing_posts_owner_id_fkey(full_name, email)`)
180
+ .or('status.in.(needs_review,manual_required,failed),copy_main.is.null,primary_channel_id.is.null')
181
+ .order('scheduled_at', { ascending: true, nullsFirst: false })
182
+ .limit(parseLimit(opts));
183
+ if (error)
184
+ throw error;
185
+ const enriched = (data ?? []).map((row) => {
186
+ const blockers = [];
187
+ if (!row.copy_main)
188
+ blockers.push('falta_copy');
189
+ if (!row.primary_channel_id)
190
+ blockers.push('falta_canal');
191
+ if (!row.scheduled_at)
192
+ blockers.push('falta_fecha');
193
+ const status = row.status;
194
+ if (status === 'manual_required')
195
+ blockers.push('manual_required');
196
+ if (status === 'failed')
197
+ blockers.push('failed');
198
+ if (status === 'needs_review')
199
+ blockers.push('needs_review');
200
+ return { ...row, blockers };
201
+ });
202
+ output(enriched, { pretty: !!opts.pretty, columns: parseColumns(opts) });
203
+ }
204
+ async function plannerReportsWeekly(_a, opts) {
205
+ const { client } = await getAuthedClient();
206
+ const anchor = typeof opts.week === 'string' ? new Date(`${opts.week}T00:00:00`) : new Date();
207
+ const day = anchor.getDay();
208
+ const offsetToMonday = day === 0 ? -6 : 1 - day;
209
+ const start = new Date(anchor);
210
+ start.setDate(start.getDate() + offsetToMonday);
211
+ start.setHours(0, 0, 0, 0);
212
+ const end = new Date(start);
213
+ end.setDate(end.getDate() + 7);
214
+ const { data: posts, error } = await client
215
+ .from('marketing_posts')
216
+ .select(`id, status, primary_channel_id, campaign_id,
217
+ primary_channel:marketing_channels!marketing_posts_primary_channel_id_fkey(slug),
218
+ campaign:marketing_campaigns(slug)`)
219
+ .gte('scheduled_at', start.toISOString())
220
+ .lt('scheduled_at', end.toISOString());
221
+ if (error)
222
+ throw error;
223
+ const { data: pubs } = await client
224
+ .from('marketing_publications')
225
+ .select('id, status, channel_id, marketing_channels(slug)')
226
+ .gte('created_at', start.toISOString())
227
+ .lt('created_at', end.toISOString());
228
+ const byStatus = {};
229
+ const byChannel = {};
230
+ const byCampaign = {};
231
+ const slugOf = (e) => {
232
+ if (!e)
233
+ return null;
234
+ if (Array.isArray(e))
235
+ return e[0]?.slug ?? null;
236
+ return e.slug ?? null;
237
+ };
238
+ for (const p of (posts ?? [])) {
239
+ byStatus[p.status] = (byStatus[p.status] ?? 0) + 1;
240
+ const cs = slugOf(p.primary_channel) ?? '∅';
241
+ byChannel[cs] = (byChannel[cs] ?? 0) + 1;
242
+ const cas = slugOf(p.campaign) ?? '∅';
243
+ byCampaign[cas] = (byCampaign[cas] ?? 0) + 1;
244
+ }
245
+ const pubsByStatus = {};
246
+ const errors = 0;
247
+ for (const pub of (pubs ?? [])) {
248
+ pubsByStatus[pub.status] = (pubsByStatus[pub.status] ?? 0) + 1;
249
+ }
250
+ output({
251
+ week_start: start.toISOString().slice(0, 10),
252
+ week_end: end.toISOString().slice(0, 10),
253
+ posts_total: (posts ?? []).length,
254
+ posts_by_status: byStatus,
255
+ posts_by_channel: byChannel,
256
+ posts_by_campaign: byCampaign,
257
+ publications_by_status: pubsByStatus,
258
+ publications_total: (pubs ?? []).length,
259
+ errors,
260
+ }, { pretty: !!opts.pretty });
261
+ }
262
+ // ─── Campaigns ─────────────────────────────────────────────────────────────
3
263
  async function listCampaigns(_a, opts) {
4
264
  const { client } = await getAuthedClient();
5
265
  let q = client
6
266
  .from('marketing_campaigns')
7
- .select('id, name, type, status, budget, start_date, end_date, assigned_to, created_at')
267
+ .select('id, name, slug, objective, status, start_date, end_date, budget_amount, owner_id, created_at')
8
268
  .order('created_at', { ascending: false })
9
269
  .limit(parseLimit(opts));
10
270
  if (typeof opts.status === 'string')
11
271
  q = q.eq('status', opts.status);
272
+ const { data, error } = await q;
273
+ if (error)
274
+ throw error;
275
+ output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
276
+ }
277
+ async function getCampaign(args, opts) {
278
+ const ref = args.id;
279
+ if (!ref)
280
+ throw new Error('Falta <id-or-slug>');
281
+ const { client } = await getAuthedClient();
282
+ const id = await resolveCampaign(client, ref);
283
+ const [campRes, postsRes] = await Promise.all([
284
+ client.from('marketing_campaigns').select('*').eq('id', id).single(),
285
+ client
286
+ .from('marketing_posts')
287
+ .select('id, internal_title, slug, status, scheduled_at, primary_channel_id')
288
+ .eq('campaign_id', id)
289
+ .order('scheduled_at', { ascending: true, nullsFirst: false })
290
+ .limit(50),
291
+ ]);
292
+ if (campRes.error)
293
+ throw campRes.error;
294
+ output({ campaign: campRes.data, posts: postsRes.data ?? [] }, { pretty: !!opts.pretty });
295
+ }
296
+ async function createCampaign(_a, opts) {
297
+ const name = typeof opts.name === 'string' ? opts.name : null;
298
+ if (!name)
299
+ throw new Error('--name requerido');
300
+ const slug = typeof opts.slug === 'string' && opts.slug ? slugify(opts.slug) : slugify(name);
301
+ const status = typeof opts.status === 'string' ? ensureEnum(CAMPAIGN_STATUSES, opts.status, 'campaign status') : 'draft';
302
+ const { client } = await getAuthedClient();
303
+ const ownerId = await maybeResolve(client, 'team_member', opts.ownerEmail);
304
+ const callerId = await getCallerTeamMemberId(client);
305
+ const payload = {
306
+ name,
307
+ slug,
308
+ objective: typeof opts.objective === 'string' ? opts.objective : null,
309
+ status,
310
+ start_date: typeof opts.startDate === 'string' ? opts.startDate : null,
311
+ end_date: typeof opts.endDate === 'string' ? opts.endDate : null,
312
+ budget_amount: typeof opts.budget === 'string' ? Number(opts.budget) : null,
313
+ primary_landing_url: typeof opts.landingUrl === 'string' ? opts.landingUrl : null,
314
+ utm_campaign: typeof opts.utmCampaign === 'string' ? opts.utmCampaign : slug,
315
+ notes: typeof opts.notes === 'string' ? opts.notes : null,
316
+ owner_id: ownerId,
317
+ created_by: callerId,
318
+ };
319
+ const { data, error } = await client.from('marketing_campaigns').insert(payload).select('*').single();
320
+ if (error)
321
+ throw error;
322
+ output(data, { pretty: !!opts.pretty });
323
+ }
324
+ async function updateCampaign(args, opts) {
325
+ const ref = args.id;
326
+ if (!ref)
327
+ throw new Error('Falta <id-or-slug>');
328
+ const { client } = await getAuthedClient();
329
+ const id = await resolveCampaign(client, ref);
330
+ const patch = {};
331
+ if (typeof opts.name === 'string')
332
+ patch.name = opts.name;
333
+ if (typeof opts.objective === 'string')
334
+ patch.objective = opts.objective;
335
+ if (typeof opts.status === 'string')
336
+ patch.status = ensureEnum(CAMPAIGN_STATUSES, opts.status, 'status');
337
+ if (typeof opts.startDate === 'string')
338
+ patch.start_date = opts.startDate;
339
+ if (typeof opts.endDate === 'string')
340
+ patch.end_date = opts.endDate;
341
+ if (typeof opts.budget === 'string')
342
+ patch.budget_amount = Number(opts.budget);
343
+ if (typeof opts.landingUrl === 'string')
344
+ patch.primary_landing_url = opts.landingUrl;
345
+ if (typeof opts.utmCampaign === 'string')
346
+ patch.utm_campaign = opts.utmCampaign;
347
+ if (typeof opts.notes === 'string')
348
+ patch.notes = opts.notes;
349
+ if (typeof opts.ownerEmail === 'string') {
350
+ patch.owner_id = await maybeResolve(client, 'team_member', opts.ownerEmail);
351
+ }
352
+ if (Object.keys(patch).length === 0)
353
+ throw new Error('Sin cambios. Pasa al menos un --flag.');
354
+ const { data, error } = await client
355
+ .from('marketing_campaigns')
356
+ .update(patch)
357
+ .eq('id', id)
358
+ .select('*')
359
+ .single();
360
+ if (error)
361
+ throw error;
362
+ output(data, { pretty: !!opts.pretty });
363
+ }
364
+ async function deleteCampaign(args, opts) {
365
+ const ref = args.id;
366
+ if (!ref)
367
+ throw new Error('Falta <id-or-slug>');
368
+ if (!opts.confirm)
369
+ throw new Error('Pasa --confirm para borrar (las piezas asociadas mantienen campaign_id=NULL).');
370
+ const { client } = await getAuthedClient();
371
+ const id = await resolveCampaign(client, ref);
372
+ const { error } = await client.from('marketing_campaigns').delete().eq('id', id);
373
+ if (error)
374
+ throw error;
375
+ output({ deleted: id }, { pretty: !!opts.pretty });
376
+ }
377
+ // ─── Channels ──────────────────────────────────────────────────────────────
378
+ async function listChannels(_a, opts) {
379
+ const { client } = await getAuthedClient();
380
+ let q = client.from('marketing_channels').select('*').order('name').limit(parseLimit(opts));
12
381
  if (typeof opts.type === 'string')
13
382
  q = q.eq('type', opts.type);
383
+ if (opts.activeOnly)
384
+ q = q.eq('is_active', true);
14
385
  const { data, error } = await q;
15
386
  if (error)
16
387
  throw error;
17
388
  output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
18
389
  }
19
- async function listMarketingLeads(_a, opts) {
390
+ async function createChannel(_a, opts) {
391
+ const name = typeof opts.name === 'string' ? opts.name : null;
392
+ if (!name)
393
+ throw new Error('--name requerido');
394
+ const slug = typeof opts.slug === 'string' && opts.slug ? slugify(opts.slug) : slugify(name);
395
+ const type = ensureEnum(CHANNEL_TYPES, String(opts.type ?? ''), 'type');
396
+ const publishing_method = ensureEnum(PUBLISHING_METHODS, String(opts.publishingMethod ?? 'manual'), 'publishing-method');
397
+ const { client } = await getAuthedClient();
398
+ const payload = {
399
+ name,
400
+ slug,
401
+ type,
402
+ publishing_method,
403
+ is_active: opts.isActive === false ? false : true,
404
+ public_config: typeof opts.config === 'string' ? JSON.parse(opts.config) : {},
405
+ notes: typeof opts.notes === 'string' ? opts.notes : null,
406
+ };
407
+ const { data, error } = await client.from('marketing_channels').insert(payload).select('*').single();
408
+ if (error)
409
+ throw error;
410
+ output(data, { pretty: !!opts.pretty });
411
+ }
412
+ async function updateChannel(args, opts) {
413
+ const ref = args.id;
414
+ if (!ref)
415
+ throw new Error('Falta <id-or-slug>');
416
+ const { client } = await getAuthedClient();
417
+ const id = await resolveChannel(client, ref);
418
+ const patch = {};
419
+ if (typeof opts.name === 'string')
420
+ patch.name = opts.name;
421
+ if (typeof opts.publishingMethod === 'string')
422
+ patch.publishing_method = ensureEnum(PUBLISHING_METHODS, opts.publishingMethod, 'publishing-method');
423
+ if (typeof opts.isActive === 'boolean')
424
+ patch.is_active = opts.isActive;
425
+ if (typeof opts.notes === 'string')
426
+ patch.notes = opts.notes;
427
+ if (typeof opts.config === 'string')
428
+ patch.public_config = JSON.parse(opts.config);
429
+ if (Object.keys(patch).length === 0)
430
+ throw new Error('Sin cambios.');
431
+ const { data, error } = await client.from('marketing_channels').update(patch).eq('id', id).select('*').single();
432
+ if (error)
433
+ throw error;
434
+ output(data, { pretty: !!opts.pretty });
435
+ }
436
+ // ─── Posts ─────────────────────────────────────────────────────────────────
437
+ async function listPosts(_a, opts) {
20
438
  const { client } = await getAuthedClient();
21
439
  let q = client
22
- .from('marketing_leads')
23
- .select('id, lead_id, campaign_id, source, attribution_data, created_at')
24
- .order('created_at', { ascending: false })
440
+ .from('marketing_posts')
441
+ .select(`id, internal_title, slug, status, scheduled_at, primary_channel_id, campaign_id, owner_id, updated_at,
442
+ primary_channel:marketing_channels!marketing_posts_primary_channel_id_fkey(slug, name),
443
+ campaign:marketing_campaigns(slug, name)`)
444
+ .order('scheduled_at', { ascending: true, nullsFirst: false })
25
445
  .limit(parseLimit(opts));
26
- if (typeof opts.campaignId === 'string')
27
- q = q.eq('campaign_id', opts.campaignId);
28
- if (typeof opts.source === 'string')
29
- q = q.eq('source', opts.source);
446
+ if (typeof opts.status === 'string')
447
+ q = q.eq('status', opts.status);
448
+ if (typeof opts.from === 'string')
449
+ q = q.gte('scheduled_at', opts.from);
450
+ if (typeof opts.to === 'string')
451
+ q = q.lte('scheduled_at', opts.to);
452
+ if (typeof opts.channel === 'string') {
453
+ const channelId = await resolveChannel(client, opts.channel);
454
+ q = q.eq('primary_channel_id', channelId);
455
+ }
456
+ if (typeof opts.campaign === 'string') {
457
+ const campaignId = await resolveCampaign(client, opts.campaign);
458
+ q = q.eq('campaign_id', campaignId);
459
+ }
30
460
  const { data, error } = await q;
31
461
  if (error)
32
462
  throw error;
33
463
  output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
34
464
  }
35
- async function listMetrics(_a, opts) {
465
+ async function getPost(args, opts) {
466
+ const ref = args.id;
467
+ if (!ref)
468
+ throw new Error('Falta <id-or-slug>');
469
+ const { client } = await getAuthedClient();
470
+ const id = await resolvePost(client, ref);
471
+ const [postRes, assetsRes, pubsRes, eventsRes, reviewsRes] = await Promise.all([
472
+ client
473
+ .from('marketing_posts')
474
+ .select(`*, campaign:marketing_campaigns(*), primary_channel:marketing_channels!marketing_posts_primary_channel_id_fkey(*)`)
475
+ .eq('id', id)
476
+ .single(),
477
+ client
478
+ .from('marketing_post_assets')
479
+ .select('*, asset:marketing_assets(*)')
480
+ .eq('post_id', id)
481
+ .order('sort_order'),
482
+ client
483
+ .from('marketing_publications')
484
+ .select('*, channel:marketing_channels(slug, name)')
485
+ .eq('post_id', id)
486
+ .order('created_at', { ascending: false }),
487
+ client
488
+ .from('marketing_status_events')
489
+ .select('*')
490
+ .eq('post_id', id)
491
+ .order('created_at', { ascending: false })
492
+ .limit(20),
493
+ client
494
+ .from('marketing_reviews')
495
+ .select('*')
496
+ .eq('post_id', id)
497
+ .order('created_at', { ascending: false }),
498
+ ]);
499
+ if (postRes.error)
500
+ throw postRes.error;
501
+ output({
502
+ post: postRes.data,
503
+ assets: assetsRes.data ?? [],
504
+ publications: pubsRes.data ?? [],
505
+ status_events: eventsRes.data ?? [],
506
+ reviews: reviewsRes.data ?? [],
507
+ }, { pretty: !!opts.pretty });
508
+ }
509
+ async function createPost(_a, opts) {
510
+ const title = typeof opts.title === 'string' ? opts.title : null;
511
+ if (!title)
512
+ throw new Error('--title requerido');
513
+ const channelRef = typeof opts.channel === 'string' ? opts.channel : null;
514
+ if (!channelRef)
515
+ throw new Error('--channel <slug-or-id> requerido');
516
+ const { client } = await getAuthedClient();
517
+ const channelId = await resolveChannel(client, channelRef);
518
+ const campaignId = typeof opts.campaign === 'string' ? await resolveCampaign(client, opts.campaign) : null;
519
+ const ownerId = await maybeResolve(client, 'team_member', opts.ownerEmail);
520
+ const callerId = await getCallerTeamMemberId(client);
521
+ const status = typeof opts.status === 'string' ? ensureEnum(POST_STATUSES, opts.status, 'status') : 'draft';
522
+ const slug = typeof opts.slug === 'string' && opts.slug ? slugify(opts.slug) : slugify(title);
523
+ const payload = {
524
+ internal_title: title,
525
+ slug,
526
+ campaign_id: campaignId,
527
+ primary_channel_id: channelId,
528
+ audience: typeof opts.audience === 'string' ? opts.audience : null,
529
+ objective: typeof opts.objective === 'string' ? opts.objective : null,
530
+ format: typeof opts.format === 'string' ? opts.format : null,
531
+ copy_main: typeof opts.copy === 'string' ? opts.copy : null,
532
+ cta_label: typeof opts.ctaLabel === 'string' ? opts.ctaLabel : null,
533
+ cta_url: typeof opts.ctaUrl === 'string' ? opts.ctaUrl : null,
534
+ owner_id: ownerId,
535
+ status,
536
+ scheduled_at: typeof opts.scheduledAt === 'string' ? new Date(opts.scheduledAt).toISOString() : null,
537
+ internal_notes: typeof opts.notes === 'string' ? opts.notes : null,
538
+ hashtags: typeof opts.hashtags === 'string'
539
+ ? opts.hashtags.split(',').map((s) => s.trim().replace(/^#/, '')).filter(Boolean)
540
+ : [],
541
+ created_by: callerId,
542
+ };
543
+ const { data, error } = await client.from('marketing_posts').insert(payload).select('*').single();
544
+ if (error)
545
+ throw error;
546
+ const post = data;
547
+ await client.from('marketing_status_events').insert({
548
+ post_id: post.id,
549
+ from_status: null,
550
+ to_status: status,
551
+ changed_by: callerId,
552
+ changed_by_type: 'user',
553
+ });
554
+ output(data, { pretty: !!opts.pretty });
555
+ }
556
+ async function updatePost(args, opts) {
557
+ const ref = args.id;
558
+ if (!ref)
559
+ throw new Error('Falta <id-or-slug>');
36
560
  const { client } = await getAuthedClient();
561
+ const id = await resolvePost(client, ref);
562
+ const patch = {};
563
+ if (typeof opts.title === 'string')
564
+ patch.internal_title = opts.title;
565
+ if (typeof opts.copy === 'string')
566
+ patch.copy_main = opts.copy;
567
+ if (typeof opts.ctaLabel === 'string')
568
+ patch.cta_label = opts.ctaLabel;
569
+ if (typeof opts.ctaUrl === 'string')
570
+ patch.cta_url = opts.ctaUrl;
571
+ if (typeof opts.finalUrl === 'string')
572
+ patch.final_url = opts.finalUrl;
573
+ if (typeof opts.audience === 'string')
574
+ patch.audience = opts.audience;
575
+ if (typeof opts.objective === 'string')
576
+ patch.objective = opts.objective;
577
+ if (typeof opts.format === 'string')
578
+ patch.format = opts.format;
579
+ if (typeof opts.notes === 'string')
580
+ patch.internal_notes = opts.notes;
581
+ if (typeof opts.scheduledAt === 'string')
582
+ patch.scheduled_at = new Date(opts.scheduledAt).toISOString();
583
+ if (typeof opts.hashtags === 'string')
584
+ patch.hashtags = opts.hashtags.split(',').map((s) => s.trim().replace(/^#/, '')).filter(Boolean);
585
+ if (typeof opts.campaign === 'string')
586
+ patch.campaign_id = await resolveCampaign(client, opts.campaign);
587
+ if (typeof opts.channel === 'string')
588
+ patch.primary_channel_id = await resolveChannel(client, opts.channel);
589
+ if (typeof opts.ownerEmail === 'string')
590
+ patch.owner_id = await maybeResolve(client, 'team_member', opts.ownerEmail);
591
+ if (Object.keys(patch).length === 0)
592
+ throw new Error('Sin cambios.');
593
+ const { data, error } = await client.from('marketing_posts').update(patch).eq('id', id).select('*').single();
594
+ if (error)
595
+ throw error;
596
+ output(data, { pretty: !!opts.pretty });
597
+ }
598
+ async function setPostStatus(args, opts) {
599
+ const ref = args.id;
600
+ const newStatusRaw = args.status;
601
+ if (!ref)
602
+ throw new Error('Falta <id-or-slug>');
603
+ if (!newStatusRaw)
604
+ throw new Error('Falta <new-status>');
605
+ const newStatus = ensureEnum(POST_STATUSES, newStatusRaw, 'status');
606
+ const { client } = await getAuthedClient();
607
+ const id = await resolvePost(client, ref);
608
+ const { data: prev, error: prevErr } = await client
609
+ .from('marketing_posts')
610
+ .select('status')
611
+ .eq('id', id)
612
+ .single();
613
+ if (prevErr)
614
+ throw prevErr;
615
+ const previousStatus = prev.status;
616
+ const { error } = await client.from('marketing_posts').update({ status: newStatus }).eq('id', id);
617
+ if (error)
618
+ throw error;
619
+ const callerId = await getCallerTeamMemberId(client);
620
+ await client.from('marketing_status_events').insert({
621
+ post_id: id,
622
+ from_status: previousStatus,
623
+ to_status: newStatus,
624
+ changed_by: callerId,
625
+ changed_by_type: 'user',
626
+ reason: typeof opts.reason === 'string' ? opts.reason : null,
627
+ });
628
+ output({ post_id: id, from: previousStatus, to: newStatus, reason: opts.reason ?? null }, { pretty: !!opts.pretty });
629
+ }
630
+ async function deletePost(args, opts) {
631
+ const ref = args.id;
632
+ if (!ref)
633
+ throw new Error('Falta <id-or-slug>');
634
+ if (!opts.confirm)
635
+ throw new Error('Pasa --confirm para borrar (cascade en assets, publications, events).');
636
+ const { client } = await getAuthedClient();
637
+ const id = await resolvePost(client, ref);
638
+ const { error } = await client.from('marketing_posts').delete().eq('id', id);
639
+ if (error)
640
+ throw error;
641
+ output({ deleted: id }, { pretty: !!opts.pretty });
642
+ }
643
+ // ─── Post Assets ───────────────────────────────────────────────────────────
644
+ async function listPostAssets(args, opts) {
645
+ const ref = args.postId;
646
+ if (!ref)
647
+ throw new Error('Falta <post-id>');
648
+ const { client } = await getAuthedClient();
649
+ const id = await resolvePost(client, ref);
650
+ const { data, error } = await client
651
+ .from('marketing_post_assets')
652
+ .select('*, asset:marketing_assets(*)')
653
+ .eq('post_id', id)
654
+ .order('sort_order');
655
+ if (error)
656
+ throw error;
657
+ output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
658
+ }
659
+ async function addPostAsset(args, opts) {
660
+ const ref = args.postId;
661
+ if (!ref)
662
+ throw new Error('Falta <post-id>');
663
+ const value = typeof opts.assetIdOrUrl === 'string' ? opts.assetIdOrUrl : null;
664
+ if (!value)
665
+ throw new Error('--asset-id-or-url requerido');
666
+ const role = typeof opts.role === 'string' ? ensureEnum(ASSET_ROLES, opts.role, 'role') : 'primary';
667
+ const sortOrder = typeof opts.sortOrder === 'string' ? Number(opts.sortOrder) : 0;
668
+ const { client } = await getAuthedClient();
669
+ const postId = await resolvePost(client, ref);
670
+ let assetId;
671
+ if (UUID_RE.test(value)) {
672
+ assetId = value;
673
+ }
674
+ else {
675
+ // Tratamos como URL externa.
676
+ const guess = value.match(/\.(mp4|mov|webm)/i)
677
+ ? 'video'
678
+ : value.match(/\.(jpg|jpeg|png|gif|webp|avif)/i)
679
+ ? 'image'
680
+ : 'other';
681
+ const callerId = await getCallerTeamMemberId(client);
682
+ const { data: created, error: cErr } = await client
683
+ .from('marketing_assets')
684
+ .insert({
685
+ asset_type: guess,
686
+ url: value,
687
+ storage_provider: 'external',
688
+ created_by: callerId,
689
+ })
690
+ .select('id')
691
+ .single();
692
+ if (cErr)
693
+ throw cErr;
694
+ assetId = created.id;
695
+ }
696
+ const { data, error } = await client
697
+ .from('marketing_post_assets')
698
+ .insert({ post_id: postId, asset_id: assetId, role, sort_order: sortOrder })
699
+ .select('*')
700
+ .single();
701
+ if (error)
702
+ throw error;
703
+ output(data, { pretty: !!opts.pretty });
704
+ }
705
+ async function removePostAsset(args, opts) {
706
+ const postRef = args.postId;
707
+ const assetId = args.assetId;
708
+ if (!postRef || !assetId)
709
+ throw new Error('Faltan args <post-id> <asset-id>');
710
+ const { client } = await getAuthedClient();
711
+ const postId = await resolvePost(client, postRef);
37
712
  let q = client
38
- .from('marketing_metrics')
39
- .select('id, campaign_id, post_id, platform, metric_type, metric_date, value, created_at')
40
- .order('metric_date', { ascending: false })
713
+ .from('marketing_post_assets')
714
+ .delete()
715
+ .eq('post_id', postId)
716
+ .eq('asset_id', assetId);
717
+ if (typeof opts.role === 'string')
718
+ q = q.eq('role', ensureEnum(ASSET_ROLES, opts.role, 'role'));
719
+ const { error } = await q;
720
+ if (error)
721
+ throw error;
722
+ output({ removed: { post_id: postId, asset_id: assetId } }, { pretty: !!opts.pretty });
723
+ }
724
+ // ─── Assets ────────────────────────────────────────────────────────────────
725
+ async function listAssets(_a, opts) {
726
+ const { client } = await getAuthedClient();
727
+ let q = client
728
+ .from('marketing_assets')
729
+ .select('*')
730
+ .order('created_at', { ascending: false })
41
731
  .limit(parseLimit(opts));
42
- if (typeof opts.campaignId === 'string')
43
- q = q.eq('campaign_id', opts.campaignId);
44
- if (typeof opts.platform === 'string')
45
- q = q.eq('platform', opts.platform);
732
+ if (typeof opts.type === 'string')
733
+ q = q.eq('asset_type', ensureEnum(ASSET_TYPES, opts.type, 'type'));
46
734
  if (typeof opts.since === 'string')
47
- q = q.gte('metric_date', opts.since);
735
+ q = q.gte('created_at', opts.since);
48
736
  const { data, error } = await q;
49
737
  if (error)
50
738
  throw error;
51
739
  output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
52
740
  }
53
- async function listPosts(_a, opts) {
741
+ async function createAsset(_a, opts) {
742
+ const url = typeof opts.url === 'string' ? opts.url : null;
743
+ if (!url)
744
+ throw new Error('--url requerido');
745
+ const type = ensureEnum(ASSET_TYPES, String(opts.type ?? 'image'), 'type');
54
746
  const { client } = await getAuthedClient();
55
- let q = client
56
- .from('social_media_posts')
747
+ const callerId = await getCallerTeamMemberId(client);
748
+ const { data, error } = await client
749
+ .from('marketing_assets')
750
+ .insert({
751
+ asset_type: type,
752
+ url,
753
+ storage_provider: 'external',
754
+ filename: typeof opts.filename === 'string' ? opts.filename : null,
755
+ alt_text: typeof opts.altText === 'string' ? opts.altText : null,
756
+ notes: typeof opts.notes === 'string' ? opts.notes : null,
757
+ created_by: callerId,
758
+ })
57
759
  .select('*')
58
- .order('created_at', { ascending: false })
760
+ .single();
761
+ if (error)
762
+ throw error;
763
+ output(data, { pretty: !!opts.pretty });
764
+ }
765
+ // ─── Publications ──────────────────────────────────────────────────────────
766
+ async function listPublications(_a, opts) {
767
+ const { client } = await getAuthedClient();
768
+ let q = client
769
+ .from('marketing_publications')
770
+ .select(`*, channel:marketing_channels(slug, name), post:marketing_posts(internal_title, slug)`)
771
+ .order('scheduled_at', { ascending: false, nullsFirst: false })
59
772
  .limit(parseLimit(opts));
60
- if (typeof opts.platform === 'string')
61
- q = q.eq('platform', opts.platform);
773
+ if (typeof opts.status === 'string')
774
+ q = q.eq('status', ensureEnum(PUBLICATION_STATUSES, opts.status, 'status'));
775
+ if (typeof opts.channel === 'string') {
776
+ const channelId = await resolveChannel(client, opts.channel);
777
+ q = q.eq('channel_id', channelId);
778
+ }
779
+ if (typeof opts.from === 'string')
780
+ q = q.gte('scheduled_at', opts.from);
781
+ if (typeof opts.to === 'string')
782
+ q = q.lte('scheduled_at', opts.to);
62
783
  const { data, error } = await q;
63
784
  if (error)
64
785
  throw error;
65
786
  output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
66
787
  }
67
- async function listAssets(_a, opts) {
788
+ async function listPostPublications(args, opts) {
789
+ const ref = args.postId;
790
+ if (!ref)
791
+ throw new Error('Falta <post-id>');
68
792
  const { client } = await getAuthedClient();
793
+ const id = await resolvePost(client, ref);
69
794
  const { data, error } = await client
70
- .from('marketing_assets')
795
+ .from('marketing_publications')
796
+ .select('*, channel:marketing_channels(slug, name)')
797
+ .eq('post_id', id)
798
+ .order('created_at', { ascending: false });
799
+ if (error)
800
+ throw error;
801
+ output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
802
+ }
803
+ async function createPublication(args, opts) {
804
+ const ref = args.postId;
805
+ const channelRef = typeof opts.channel === 'string' ? opts.channel : null;
806
+ if (!ref)
807
+ throw new Error('Falta <post-id>');
808
+ if (!channelRef)
809
+ throw new Error('--channel <slug-or-id> requerido');
810
+ const { client } = await getAuthedClient();
811
+ const postId = await resolvePost(client, ref);
812
+ const channelId = await resolveChannel(client, channelRef);
813
+ const status = typeof opts.status === 'string'
814
+ ? ensureEnum(PUBLICATION_STATUSES, opts.status, 'status')
815
+ : 'pending';
816
+ const { data, error } = await client
817
+ .from('marketing_publications')
818
+ .insert({
819
+ post_id: postId,
820
+ channel_id: channelId,
821
+ status,
822
+ scheduled_at: typeof opts.scheduledAt === 'string' ? new Date(opts.scheduledAt).toISOString() : null,
823
+ })
824
+ .select('*')
825
+ .single();
826
+ if (error)
827
+ throw error;
828
+ output(data, { pretty: !!opts.pretty });
829
+ }
830
+ async function updatePublication(args, opts) {
831
+ const id = args.id;
832
+ if (!id || !UUID_RE.test(id))
833
+ throw new Error('Falta <id> de la publicación (uuid)');
834
+ const patch = {};
835
+ if (typeof opts.status === 'string')
836
+ patch.status = ensureEnum(PUBLICATION_STATUSES, opts.status, 'status');
837
+ if (typeof opts.externalUrl === 'string')
838
+ patch.external_url = opts.externalUrl;
839
+ if (typeof opts.externalId === 'string')
840
+ patch.external_id = opts.externalId;
841
+ if (typeof opts.evidenceUrl === 'string')
842
+ patch.evidence_url = opts.evidenceUrl;
843
+ if (typeof opts.publishedAt === 'string')
844
+ patch.published_at = new Date(opts.publishedAt).toISOString();
845
+ if (typeof opts.errorMessage === 'string')
846
+ patch.error_message = opts.errorMessage;
847
+ if (Object.keys(patch).length === 0)
848
+ throw new Error('Sin cambios.');
849
+ const { client } = await getAuthedClient();
850
+ const { data, error } = await client
851
+ .from('marketing_publications')
852
+ .update(patch)
853
+ .eq('id', id)
854
+ .select('*')
855
+ .single();
856
+ if (error)
857
+ throw error;
858
+ output(data, { pretty: !!opts.pretty });
859
+ }
860
+ async function markPublicationPublished(args, opts) {
861
+ const id = args.id;
862
+ if (!id || !UUID_RE.test(id))
863
+ throw new Error('Falta <id> de la publicación (uuid)');
864
+ if (typeof opts.externalUrl !== 'string' || !opts.externalUrl)
865
+ throw new Error('--external-url requerido');
866
+ const { client } = await getAuthedClient();
867
+ const callerId = await getCallerTeamMemberId(client);
868
+ const { data, error } = await client
869
+ .from('marketing_publications')
870
+ .update({
871
+ status: 'published',
872
+ external_url: opts.externalUrl,
873
+ evidence_url: typeof opts.evidenceUrl === 'string' ? opts.evidenceUrl : null,
874
+ published_at: typeof opts.publishedAt === 'string'
875
+ ? new Date(opts.publishedAt).toISOString()
876
+ : new Date().toISOString(),
877
+ published_by: callerId,
878
+ })
879
+ .eq('id', id)
880
+ .select('*')
881
+ .single();
882
+ if (error)
883
+ throw error;
884
+ output(data, { pretty: !!opts.pretty });
885
+ }
886
+ // ─── Events (status histórico) ─────────────────────────────────────────────
887
+ async function listPostEvents(args, opts) {
888
+ const ref = args.postId;
889
+ if (!ref)
890
+ throw new Error('Falta <post-id>');
891
+ const { client } = await getAuthedClient();
892
+ const id = await resolvePost(client, ref);
893
+ const { data, error } = await client
894
+ .from('marketing_status_events')
71
895
  .select('*')
896
+ .eq('post_id', id)
72
897
  .order('created_at', { ascending: false })
73
898
  .limit(parseLimit(opts));
74
899
  if (error)
75
900
  throw error;
76
901
  output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
77
902
  }
903
+ // ─── Reviews ───────────────────────────────────────────────────────────────
904
+ async function requestReview(args, opts) {
905
+ const ref = args.postId;
906
+ if (!ref)
907
+ throw new Error('Falta <post-id>');
908
+ const { client } = await getAuthedClient();
909
+ const postId = await resolvePost(client, ref);
910
+ const reviewerId = await maybeResolve(client, 'team_member', opts.reviewerEmail);
911
+ const { data, error } = await client
912
+ .from('marketing_reviews')
913
+ .insert({ post_id: postId, reviewer_id: reviewerId, status: 'requested' })
914
+ .select('*')
915
+ .single();
916
+ if (error)
917
+ throw error;
918
+ output(data, { pretty: !!opts.pretty });
919
+ }
920
+ async function resolveReview(args, opts) {
921
+ const id = args.id;
922
+ if (!id || !UUID_RE.test(id))
923
+ throw new Error('Falta <id> de la revisión (uuid)');
924
+ const status = ensureEnum(REVIEW_STATUSES, String(opts.status ?? ''), 'review status');
925
+ if (status === 'requested')
926
+ throw new Error('Usa otro estado: approved | changes_requested | rejected');
927
+ const { client } = await getAuthedClient();
928
+ const { data, error } = await client
929
+ .from('marketing_reviews')
930
+ .update({
931
+ status,
932
+ comment: typeof opts.comment === 'string' ? opts.comment : null,
933
+ resolved_at: new Date().toISOString(),
934
+ })
935
+ .eq('id', id)
936
+ .select('*')
937
+ .single();
938
+ if (error)
939
+ throw error;
940
+ output(data, { pretty: !!opts.pretty });
941
+ }
942
+ // ─── Metrics ───────────────────────────────────────────────────────────────
943
+ async function listMetrics(_a, opts) {
944
+ const { client } = await getAuthedClient();
945
+ let q = client
946
+ .from('marketing_metrics')
947
+ .select('*')
948
+ .order('captured_at', { ascending: false })
949
+ .limit(parseLimit(opts));
950
+ if (typeof opts.publicationId === 'string')
951
+ q = q.eq('publication_id', opts.publicationId);
952
+ if (typeof opts.from === 'string')
953
+ q = q.gte('captured_at', opts.from);
954
+ if (typeof opts.to === 'string')
955
+ q = q.lte('captured_at', opts.to);
956
+ const { data, error } = await q;
957
+ if (error)
958
+ throw error;
959
+ output(data ?? [], { pretty: !!opts.pretty, columns: parseColumns(opts) });
960
+ }
961
+ async function recordMetric(_a, opts) {
962
+ const publicationId = typeof opts.publicationId === 'string' ? opts.publicationId : null;
963
+ if (!publicationId || !UUID_RE.test(publicationId))
964
+ throw new Error('--publication-id (uuid) requerido');
965
+ const source = typeof opts.source === 'string' ? ensureEnum(METRIC_SOURCES, opts.source, 'source') : 'manual';
966
+ const numericKeys = ['impressions', 'reach', 'clicks', 'likes', 'comments', 'shares', 'saves', 'leads'];
967
+ const payload = { publication_id: publicationId, source };
968
+ for (const k of numericKeys) {
969
+ if (typeof opts[k] === 'string')
970
+ payload[k] = Number(opts[k]);
971
+ }
972
+ if (typeof opts.ctr === 'string')
973
+ payload.ctr = Number(opts.ctr);
974
+ if (typeof opts.rawData === 'string')
975
+ payload.raw_data = JSON.parse(opts.rawData);
976
+ const { client } = await getAuthedClient();
977
+ const { data, error } = await client.from('marketing_metrics').insert(payload).select('*').single();
978
+ if (error)
979
+ throw error;
980
+ output(data, { pretty: !!opts.pretty });
981
+ }
982
+ // ─── Module spec ───────────────────────────────────────────────────────────
78
983
  export const marketingModule = {
79
984
  name: 'marketing',
80
- summary: 'Marketing — campaigns, attribution, metrics, posts, assets',
985
+ summary: 'Marketing Planner campañas, canales, posts, assets, publicaciones, reviews, métricas',
81
986
  specs: [
987
+ // Planner reads ────────────────────────────────────────────────────────
988
+ {
989
+ path: ['marketing', 'planner', 'today'],
990
+ summary: 'Posts programados para hoy con estado, canal y owner',
991
+ tags: ['read'],
992
+ options: commonListOptions(),
993
+ handler: plannerToday,
994
+ },
995
+ {
996
+ path: ['marketing', 'planner', 'week'],
997
+ summary: 'Posts agrupados por día de la semana',
998
+ tags: ['read'],
999
+ options: [
1000
+ { flag: '--week <YYYY-MM-DD>', description: 'fecha-ancla, default = hoy' },
1001
+ ...commonListOptions(),
1002
+ ],
1003
+ handler: plannerWeek,
1004
+ },
1005
+ {
1006
+ path: ['marketing', 'planner', 'blockers'],
1007
+ summary: 'Piezas con falta de copy/canal/fecha o estados que requieren acción',
1008
+ tags: ['read'],
1009
+ options: commonListOptions(),
1010
+ handler: plannerBlockers,
1011
+ },
1012
+ {
1013
+ path: ['marketing', 'planner', 'reports', 'weekly'],
1014
+ summary: 'Resumen semanal: conteos por estado, canal y campaña',
1015
+ tags: ['read'],
1016
+ options: [
1017
+ { flag: '--week <YYYY-MM-DD>', description: 'fecha-ancla, default = hoy' },
1018
+ ...commonListOptions(),
1019
+ ],
1020
+ handler: plannerReportsWeekly,
1021
+ },
1022
+ // Campaigns ────────────────────────────────────────────────────────────
82
1023
  {
83
1024
  path: ['marketing', 'campaigns', 'list'],
84
- summary: 'List marketing campaigns',
1025
+ summary: 'Listar campañas',
85
1026
  tags: ['read'],
86
1027
  options: [
87
- { flag: '--status <s>', description: 'status filter' },
88
- { flag: '--type <t>', description: 'type filter' },
1028
+ { flag: '--status <s>', description: `filtra estado (${CAMPAIGN_STATUSES.join('|')})` },
89
1029
  ...commonListOptions(),
90
1030
  ],
91
1031
  handler: listCampaigns,
92
1032
  },
93
1033
  {
94
- path: ['marketing', 'leads', 'list'],
95
- summary: 'List marketing_leads (attribution records)',
1034
+ path: ['marketing', 'campaigns', 'get'],
1035
+ summary: 'Detalle de campaña con sus piezas asociadas',
1036
+ args: [{ name: 'id', required: true, description: 'id (uuid) o slug' }],
96
1037
  tags: ['read'],
1038
+ options: commonListOptions(),
1039
+ handler: getCampaign,
1040
+ },
1041
+ {
1042
+ path: ['marketing', 'campaigns', 'create'],
1043
+ summary: 'Crear campaña',
1044
+ tags: ['write'],
97
1045
  options: [
98
- { flag: '--campaign-id <uuid>', description: 'filter by campaign' },
99
- { flag: '--source <s>', description: 'filter by source' },
1046
+ { flag: '--name <n>', description: 'nombre' },
1047
+ { flag: '--slug <s>', description: 'slug (auto desde name si vacío)' },
1048
+ { flag: '--objective <o>', description: 'objetivo' },
1049
+ { flag: '--status <s>', description: `estado (${CAMPAIGN_STATUSES.join('|')}), default draft` },
1050
+ { flag: '--start-date <YYYY-MM-DD>', description: 'inicio' },
1051
+ { flag: '--end-date <YYYY-MM-DD>', description: 'fin' },
1052
+ { flag: '--budget <amount>', description: 'presupuesto' },
1053
+ { flag: '--landing-url <url>', description: 'landing principal' },
1054
+ { flag: '--utm-campaign <v>', description: 'override utm_campaign (default = slug)' },
1055
+ { flag: '--owner-email <e>', description: 'email del owner' },
1056
+ { flag: '--notes <n>', description: 'notas' },
100
1057
  ...commonListOptions(),
101
1058
  ],
102
- handler: listMarketingLeads,
1059
+ handler: createCampaign,
103
1060
  },
104
1061
  {
105
- path: ['marketing', 'metrics', 'list'],
106
- summary: 'List marketing_metrics',
1062
+ path: ['marketing', 'campaigns', 'update'],
1063
+ summary: 'Actualizar campaña',
1064
+ args: [{ name: 'id', required: true, description: 'id (uuid) o slug' }],
1065
+ tags: ['write'],
1066
+ options: [
1067
+ { flag: '--name <n>', description: 'nuevo nombre' },
1068
+ { flag: '--objective <o>', description: 'objetivo' },
1069
+ { flag: '--status <s>', description: `estado (${CAMPAIGN_STATUSES.join('|')})` },
1070
+ { flag: '--start-date <d>', description: 'inicio' },
1071
+ { flag: '--end-date <d>', description: 'fin' },
1072
+ { flag: '--budget <n>', description: 'presupuesto' },
1073
+ { flag: '--landing-url <u>', description: 'landing' },
1074
+ { flag: '--utm-campaign <v>', description: 'utm_campaign' },
1075
+ { flag: '--owner-email <e>', description: 'nuevo owner' },
1076
+ { flag: '--notes <n>', description: 'notas' },
1077
+ ...commonListOptions(),
1078
+ ],
1079
+ handler: updateCampaign,
1080
+ },
1081
+ {
1082
+ path: ['marketing', 'campaigns', 'delete'],
1083
+ summary: 'Borrar campaña (las piezas mantienen campaign_id=NULL)',
1084
+ args: [{ name: 'id', required: true, description: 'id o slug' }],
1085
+ tags: ['destructive', 'write'],
1086
+ options: [
1087
+ { flag: '--confirm', description: 'requerido para confirmar el borrado' },
1088
+ ...commonListOptions(),
1089
+ ],
1090
+ handler: deleteCampaign,
1091
+ },
1092
+ // Channels ─────────────────────────────────────────────────────────────
1093
+ {
1094
+ path: ['marketing', 'channels', 'list'],
1095
+ summary: 'Listar canales',
107
1096
  tags: ['read'],
108
1097
  options: [
109
- { flag: '--campaign-id <uuid>', description: 'filter by campaign' },
110
- { flag: '--platform <p>', description: 'filter by platform' },
111
- { flag: '--since <date>', description: 'metric_date >= YYYY-MM-DD' },
1098
+ { flag: '--type <t>', description: `tipo (${CHANNEL_TYPES.join('|')})` },
1099
+ { flag: '--active-only', description: 'solo activos' },
112
1100
  ...commonListOptions(),
113
1101
  ],
114
- handler: listMetrics,
1102
+ handler: listChannels,
1103
+ },
1104
+ {
1105
+ path: ['marketing', 'channels', 'create'],
1106
+ summary: 'Crear canal',
1107
+ tags: ['write'],
1108
+ options: [
1109
+ { flag: '--name <n>', description: 'nombre' },
1110
+ { flag: '--slug <s>', description: 'slug (auto desde name)' },
1111
+ { flag: '--type <t>', description: `tipo (${CHANNEL_TYPES.join('|')})` },
1112
+ {
1113
+ flag: '--publishing-method <m>',
1114
+ description: `método (${PUBLISHING_METHODS.join('|')}), default manual`,
1115
+ },
1116
+ { flag: '--is-active', description: 'activar (default true)' },
1117
+ { flag: '--config <json>', description: 'public_config como JSON string' },
1118
+ { flag: '--notes <n>', description: 'notas' },
1119
+ ...commonListOptions(),
1120
+ ],
1121
+ handler: createChannel,
115
1122
  },
1123
+ {
1124
+ path: ['marketing', 'channels', 'update'],
1125
+ summary: 'Actualizar canal',
1126
+ args: [{ name: 'id', required: true, description: 'id (uuid) o slug' }],
1127
+ tags: ['write'],
1128
+ options: [
1129
+ { flag: '--name <n>', description: 'nombre' },
1130
+ { flag: '--publishing-method <m>', description: `método (${PUBLISHING_METHODS.join('|')})` },
1131
+ { flag: '--is-active', description: 'activar' },
1132
+ { flag: '--config <json>', description: 'public_config JSON' },
1133
+ { flag: '--notes <n>', description: 'notas' },
1134
+ ...commonListOptions(),
1135
+ ],
1136
+ handler: updateChannel,
1137
+ },
1138
+ // Posts ────────────────────────────────────────────────────────────────
116
1139
  {
117
1140
  path: ['marketing', 'posts', 'list'],
118
- summary: 'List social media posts',
1141
+ summary: 'Listar piezas con filtros por estado/canal/campaña/fecha',
119
1142
  tags: ['read'],
120
1143
  options: [
121
- { flag: '--platform <p>', description: 'filter by platform' },
1144
+ { flag: '--status <s>', description: `estado (${POST_STATUSES.join('|')})` },
1145
+ { flag: '--channel <slug-or-id>', description: 'canal principal' },
1146
+ { flag: '--campaign <slug-or-id>', description: 'campaña' },
1147
+ { flag: '--from <iso>', description: 'scheduled_at >=' },
1148
+ { flag: '--to <iso>', description: 'scheduled_at <=' },
122
1149
  ...commonListOptions(),
123
1150
  ],
124
1151
  handler: listPosts,
125
1152
  },
126
1153
  {
127
- path: ['marketing', 'assets', 'list'],
128
- summary: 'List marketing assets',
1154
+ path: ['marketing', 'posts', 'get'],
1155
+ summary: 'Pieza con assets, publicaciones, histórico de estado y reviews',
1156
+ args: [{ name: 'id', required: true, description: 'id (uuid) o slug' }],
1157
+ tags: ['read'],
1158
+ options: commonListOptions(),
1159
+ handler: getPost,
1160
+ },
1161
+ {
1162
+ path: ['marketing', 'posts', 'create'],
1163
+ summary: 'Crear nueva pieza editorial',
1164
+ tags: ['write'],
1165
+ options: [
1166
+ { flag: '--title <t>', description: 'título interno' },
1167
+ { flag: '--slug <s>', description: 'slug (auto desde title)' },
1168
+ { flag: '--channel <slug-or-id>', description: 'canal principal' },
1169
+ { flag: '--campaign <slug-or-id>', description: 'campaña' },
1170
+ { flag: '--status <s>', description: `estado (${POST_STATUSES.join('|')}), default draft` },
1171
+ { flag: '--scheduled-at <iso>', description: 'fecha/hora programada' },
1172
+ { flag: '--copy <text>', description: 'copy principal' },
1173
+ { flag: '--cta-label <t>', description: 'texto CTA' },
1174
+ { flag: '--cta-url <u>', description: 'URL CTA' },
1175
+ { flag: '--audience <a>', description: 'audiencia' },
1176
+ { flag: '--objective <o>', description: 'objetivo' },
1177
+ { flag: '--format <f>', description: 'formato' },
1178
+ { flag: '--hashtags <list>', description: 'lista separada por coma' },
1179
+ { flag: '--owner-email <e>', description: 'email del owner' },
1180
+ { flag: '--notes <n>', description: 'notas internas' },
1181
+ ...commonListOptions(),
1182
+ ],
1183
+ handler: createPost,
1184
+ },
1185
+ {
1186
+ path: ['marketing', 'posts', 'update'],
1187
+ summary: 'Actualizar pieza',
1188
+ args: [{ name: 'id', required: true, description: 'id o slug' }],
1189
+ tags: ['write'],
1190
+ options: [
1191
+ { flag: '--title <t>', description: 'título' },
1192
+ { flag: '--copy <text>', description: 'copy' },
1193
+ { flag: '--cta-label <t>', description: 'CTA texto' },
1194
+ { flag: '--cta-url <u>', description: 'CTA URL' },
1195
+ { flag: '--final-url <u>', description: 'URL final con UTM' },
1196
+ { flag: '--scheduled-at <iso>', description: 'fecha programada' },
1197
+ { flag: '--audience <a>', description: 'audiencia' },
1198
+ { flag: '--objective <o>', description: 'objetivo' },
1199
+ { flag: '--format <f>', description: 'formato' },
1200
+ { flag: '--hashtags <list>', description: 'separados por coma' },
1201
+ { flag: '--channel <slug-or-id>', description: 'canal principal' },
1202
+ { flag: '--campaign <slug-or-id>', description: 'campaña' },
1203
+ { flag: '--owner-email <e>', description: 'owner' },
1204
+ { flag: '--notes <n>', description: 'notas internas' },
1205
+ ...commonListOptions(),
1206
+ ],
1207
+ handler: updatePost,
1208
+ },
1209
+ {
1210
+ path: ['marketing', 'posts', 'status', 'set'],
1211
+ summary: 'Cambiar estado de la pieza con histórico',
1212
+ args: [
1213
+ { name: 'id', required: true, description: 'post id o slug' },
1214
+ { name: 'status', required: true, description: POST_STATUSES.join('|') },
1215
+ ],
1216
+ tags: ['write'],
1217
+ options: [{ flag: '--reason <r>', description: 'motivo del cambio' }, ...commonListOptions()],
1218
+ handler: setPostStatus,
1219
+ },
1220
+ {
1221
+ path: ['marketing', 'posts', 'delete'],
1222
+ summary: 'Borrar pieza (cascade en assets, publicaciones, eventos)',
1223
+ args: [{ name: 'id', required: true, description: 'id o slug' }],
1224
+ tags: ['destructive', 'write'],
1225
+ options: [{ flag: '--confirm', description: 'requerido' }, ...commonListOptions()],
1226
+ handler: deletePost,
1227
+ },
1228
+ // Post assets ──────────────────────────────────────────────────────────
1229
+ {
1230
+ path: ['marketing', 'posts', 'assets', 'list'],
1231
+ summary: 'Assets de una pieza',
1232
+ args: [{ name: 'postId', required: true, description: 'post id o slug' }],
129
1233
  tags: ['read'],
130
1234
  options: commonListOptions(),
1235
+ handler: listPostAssets,
1236
+ },
1237
+ {
1238
+ path: ['marketing', 'posts', 'assets', 'add'],
1239
+ summary: 'Vincular asset (uuid existente o URL externa) a la pieza',
1240
+ args: [{ name: 'postId', required: true, description: 'post id o slug' }],
1241
+ tags: ['write'],
1242
+ options: [
1243
+ { flag: '--asset-id-or-url <v>', description: 'uuid de asset o URL externa' },
1244
+ { flag: '--role <r>', description: `rol (${ASSET_ROLES.join('|')}), default primary` },
1245
+ { flag: '--sort-order <n>', description: 'orden, default 0' },
1246
+ ...commonListOptions(),
1247
+ ],
1248
+ handler: addPostAsset,
1249
+ },
1250
+ {
1251
+ path: ['marketing', 'posts', 'assets', 'remove'],
1252
+ summary: 'Desvincular asset de la pieza',
1253
+ args: [
1254
+ { name: 'postId', required: true, description: 'post id o slug' },
1255
+ { name: 'assetId', required: true, description: 'asset uuid' },
1256
+ ],
1257
+ tags: ['destructive', 'write'],
1258
+ options: [
1259
+ { flag: '--role <r>', description: 'desvincular solo en este rol' },
1260
+ ...commonListOptions(),
1261
+ ],
1262
+ handler: removePostAsset,
1263
+ },
1264
+ // Assets ───────────────────────────────────────────────────────────────
1265
+ {
1266
+ path: ['marketing', 'assets', 'list'],
1267
+ summary: 'Listar assets',
1268
+ tags: ['read'],
1269
+ options: [
1270
+ { flag: '--type <t>', description: `tipo (${ASSET_TYPES.join('|')})` },
1271
+ { flag: '--since <iso>', description: 'created_at >=' },
1272
+ ...commonListOptions(),
1273
+ ],
131
1274
  handler: listAssets,
132
1275
  },
1276
+ {
1277
+ path: ['marketing', 'assets', 'create'],
1278
+ summary: 'Registrar asset por URL externa (sin upload)',
1279
+ tags: ['write'],
1280
+ options: [
1281
+ { flag: '--url <u>', description: 'URL pública del asset' },
1282
+ { flag: '--type <t>', description: `tipo (${ASSET_TYPES.join('|')}), default image` },
1283
+ { flag: '--filename <f>', description: 'nombre del archivo' },
1284
+ { flag: '--alt-text <t>', description: 'alt text' },
1285
+ { flag: '--notes <n>', description: 'notas' },
1286
+ ...commonListOptions(),
1287
+ ],
1288
+ handler: createAsset,
1289
+ },
1290
+ // Publications ─────────────────────────────────────────────────────────
1291
+ {
1292
+ path: ['marketing', 'publications', 'list'],
1293
+ summary: 'Listar publicaciones',
1294
+ tags: ['read'],
1295
+ options: [
1296
+ { flag: '--status <s>', description: `estado (${PUBLICATION_STATUSES.join('|')})` },
1297
+ { flag: '--channel <slug-or-id>', description: 'canal' },
1298
+ { flag: '--from <iso>', description: 'scheduled_at >=' },
1299
+ { flag: '--to <iso>', description: 'scheduled_at <=' },
1300
+ ...commonListOptions(),
1301
+ ],
1302
+ handler: listPublications,
1303
+ },
1304
+ {
1305
+ path: ['marketing', 'posts', 'publications', 'list'],
1306
+ summary: 'Publicaciones de una pieza',
1307
+ args: [{ name: 'postId', required: true, description: 'post id o slug' }],
1308
+ tags: ['read'],
1309
+ options: commonListOptions(),
1310
+ handler: listPostPublications,
1311
+ },
1312
+ {
1313
+ path: ['marketing', 'posts', 'publications', 'create'],
1314
+ summary: 'Crear publicación para un canal',
1315
+ args: [{ name: 'postId', required: true, description: 'post id o slug' }],
1316
+ tags: ['write'],
1317
+ options: [
1318
+ { flag: '--channel <slug-or-id>', description: 'canal' },
1319
+ { flag: '--scheduled-at <iso>', description: 'fecha programada' },
1320
+ {
1321
+ flag: '--status <s>',
1322
+ description: `estado (${PUBLICATION_STATUSES.join('|')}), default pending`,
1323
+ },
1324
+ ...commonListOptions(),
1325
+ ],
1326
+ handler: createPublication,
1327
+ },
1328
+ {
1329
+ path: ['marketing', 'publications', 'update'],
1330
+ summary: 'Actualizar publicación (URL final, evidencia, status, error)',
1331
+ args: [{ name: 'id', required: true, description: 'publication uuid' }],
1332
+ tags: ['write'],
1333
+ options: [
1334
+ { flag: '--status <s>', description: `estado (${PUBLICATION_STATUSES.join('|')})` },
1335
+ { flag: '--external-url <u>', description: 'URL pública publicada' },
1336
+ { flag: '--external-id <id>', description: 'id externo (post id de la red)' },
1337
+ { flag: '--evidence-url <u>', description: 'screenshot/log URL' },
1338
+ { flag: '--published-at <iso>', description: 'fecha real publicación' },
1339
+ { flag: '--error-message <m>', description: 'mensaje de error si falló' },
1340
+ ...commonListOptions(),
1341
+ ],
1342
+ handler: updatePublication,
1343
+ },
1344
+ {
1345
+ path: ['marketing', 'publications', 'mark-published'],
1346
+ summary: 'Atajo: marcar publicada con URL final y evidencia',
1347
+ args: [{ name: 'id', required: true, description: 'publication uuid' }],
1348
+ tags: ['write'],
1349
+ options: [
1350
+ { flag: '--external-url <u>', description: 'URL final (requerido)' },
1351
+ { flag: '--evidence-url <u>', description: 'screenshot/log URL' },
1352
+ { flag: '--published-at <iso>', description: 'fecha (default ahora)' },
1353
+ ...commonListOptions(),
1354
+ ],
1355
+ handler: markPublicationPublished,
1356
+ },
1357
+ // Events / status histórico ────────────────────────────────────────────
1358
+ {
1359
+ path: ['marketing', 'posts', 'events', 'list'],
1360
+ summary: 'Histórico de cambios de estado de una pieza',
1361
+ args: [{ name: 'postId', required: true, description: 'post id o slug' }],
1362
+ tags: ['read'],
1363
+ options: commonListOptions(),
1364
+ handler: listPostEvents,
1365
+ },
1366
+ // Reviews ──────────────────────────────────────────────────────────────
1367
+ {
1368
+ path: ['marketing', 'reviews', 'request'],
1369
+ summary: 'Solicitar revisión de una pieza',
1370
+ args: [{ name: 'postId', required: true, description: 'post id o slug' }],
1371
+ tags: ['write'],
1372
+ options: [
1373
+ { flag: '--reviewer-email <e>', description: 'email del reviewer' },
1374
+ ...commonListOptions(),
1375
+ ],
1376
+ handler: requestReview,
1377
+ },
1378
+ {
1379
+ path: ['marketing', 'reviews', 'resolve'],
1380
+ summary: 'Resolver revisión (approved | changes_requested | rejected)',
1381
+ args: [{ name: 'id', required: true, description: 'review uuid' }],
1382
+ tags: ['write'],
1383
+ options: [
1384
+ { flag: '--status <s>', description: `(${REVIEW_STATUSES.filter((s) => s !== 'requested').join('|')})` },
1385
+ { flag: '--comment <c>', description: 'comentario' },
1386
+ ...commonListOptions(),
1387
+ ],
1388
+ handler: resolveReview,
1389
+ },
1390
+ // Metrics ──────────────────────────────────────────────────────────────
1391
+ {
1392
+ path: ['marketing', 'metrics', 'list'],
1393
+ summary: 'Métricas registradas',
1394
+ tags: ['read'],
1395
+ options: [
1396
+ { flag: '--publication-id <uuid>', description: 'filtrar por publicación' },
1397
+ { flag: '--from <iso>', description: 'captured_at >=' },
1398
+ { flag: '--to <iso>', description: 'captured_at <=' },
1399
+ ...commonListOptions(),
1400
+ ],
1401
+ handler: listMetrics,
1402
+ },
1403
+ {
1404
+ path: ['marketing', 'metrics', 'record'],
1405
+ summary: 'Registrar métricas para una publicación',
1406
+ tags: ['write'],
1407
+ options: [
1408
+ { flag: '--publication-id <uuid>', description: 'requerido' },
1409
+ { flag: '--impressions <n>', description: 'impresiones' },
1410
+ { flag: '--reach <n>', description: 'reach' },
1411
+ { flag: '--clicks <n>', description: 'clicks' },
1412
+ { flag: '--likes <n>', description: 'likes' },
1413
+ { flag: '--comments <n>', description: 'comments' },
1414
+ { flag: '--shares <n>', description: 'shares' },
1415
+ { flag: '--saves <n>', description: 'saves' },
1416
+ { flag: '--leads <n>', description: 'leads atribuidos' },
1417
+ { flag: '--ctr <decimal>', description: 'CTR (0-1)' },
1418
+ { flag: '--source <s>', description: `(${METRIC_SOURCES.join('|')}), default manual` },
1419
+ { flag: '--raw-data <json>', description: 'JSON crudo extra' },
1420
+ ...commonListOptions(),
1421
+ ],
1422
+ handler: recordMetric,
1423
+ },
133
1424
  ],
134
1425
  };
135
1426
  //# sourceMappingURL=marketing.js.map