@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.
- package/AGENTS.md +143 -1
- package/CHANGELOG.md +114 -0
- package/README.md +48 -3
- package/dist/bin.js +1 -32
- package/dist/bin.js.map +1 -1
- package/dist/commands/federations.js +466 -0
- package/dist/commands/federations.js.map +1 -1
- package/dist/commands/marketing.js +1336 -45
- package/dist/commands/marketing.js.map +1 -1
- package/dist/core/auth-context.js +12 -0
- package/dist/core/auth-context.js.map +1 -0
- package/dist/core/supabase.js +15 -3
- package/dist/core/supabase.js.map +1 -1
- package/dist/mcp/server.js +1 -110
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/shared.js +112 -0
- package/dist/mcp/shared.js.map +1 -0
- package/dist/modules.js +40 -0
- package/dist/modules.js.map +1 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
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('
|
|
23
|
-
.select(
|
|
24
|
-
|
|
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.
|
|
27
|
-
q = q.eq('
|
|
28
|
-
if (typeof opts.
|
|
29
|
-
q = q.
|
|
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
|
|
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('
|
|
39
|
-
.
|
|
40
|
-
.
|
|
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.
|
|
43
|
-
q = q.eq('
|
|
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('
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
.
|
|
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.
|
|
61
|
-
q = q.eq('
|
|
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
|
|
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('
|
|
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 —
|
|
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: '
|
|
1025
|
+
summary: 'Listar campañas',
|
|
85
1026
|
tags: ['read'],
|
|
86
1027
|
options: [
|
|
87
|
-
{ flag: '--status <s>', description:
|
|
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', '
|
|
95
|
-
summary: '
|
|
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: '--
|
|
99
|
-
{ flag: '--
|
|
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:
|
|
1059
|
+
handler: createCampaign,
|
|
103
1060
|
},
|
|
104
1061
|
{
|
|
105
|
-
path: ['marketing', '
|
|
106
|
-
summary: '
|
|
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: '--
|
|
110
|
-
{ flag: '--
|
|
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:
|
|
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: '
|
|
1141
|
+
summary: 'Listar piezas con filtros por estado/canal/campaña/fecha',
|
|
119
1142
|
tags: ['read'],
|
|
120
1143
|
options: [
|
|
121
|
-
{ flag: '--
|
|
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', '
|
|
128
|
-
summary: '
|
|
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
|