@grapadigital/shared-schemas 1.0.220 → 1.0.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -36,3 +36,5 @@ export * from './schemas/export-comments-job.schema';
36
36
  export * from './schemas/job.schema';
37
37
  export * from './schemas/snapshot.schema';
38
38
  export * from './schemas/lead.schema';
39
+ export * from './utils/normalize-content-link.util';
40
+ export * from './utils/merge-metrics-breakdown.util';
package/dist/index.js CHANGED
@@ -57,3 +57,5 @@ __exportStar(require("./schemas/export-comments-job.schema"), exports);
57
57
  __exportStar(require("./schemas/job.schema"), exports);
58
58
  __exportStar(require("./schemas/snapshot.schema"), exports);
59
59
  __exportStar(require("./schemas/lead.schema"), exports);
60
+ __exportStar(require("./utils/normalize-content-link.util"), exports);
61
+ __exportStar(require("./utils/merge-metrics-breakdown.util"), exports);
@@ -91,7 +91,8 @@ export declare class Action {
91
91
  nps?: number;
92
92
  status: ActionStatus;
93
93
  partnership: 'partner' | 'exclusive';
94
- type: 'seeding' | 'ugc' | 'creator';
94
+ /** organic = Orgânicos do mês: post aprovado numa Ação de produto sai dela. Ver GD-699. */
95
+ type: 'seeding' | 'ugc' | 'creator' | 'organic';
95
96
  socialnetworks?: string[];
96
97
  recruitedInfluencers?: number;
97
98
  hiredInfluencers?: number;
@@ -170,8 +170,8 @@ __decorate([
170
170
  __metadata("design:type", String)
171
171
  ], Action.prototype, "partnership", void 0);
172
172
  __decorate([
173
- (0, swagger_1.ApiProperty)({ enum: ['seeding', 'ugc', 'creator'], example: 'seeding' }),
174
- (0, mongoose_1.Prop)({ type: String, enum: ['seeding', 'ugc', 'creator'], required: true }),
173
+ (0, swagger_1.ApiProperty)({ enum: ['seeding', 'ugc', 'creator', 'organic'], example: 'seeding' }),
174
+ (0, mongoose_1.Prop)({ type: String, enum: ['seeding', 'ugc', 'creator', 'organic'], required: true }),
175
175
  __metadata("design:type", String)
176
176
  ], Action.prototype, "type", void 0);
177
177
  __decorate([
@@ -113,6 +113,15 @@ export declare class Metrics {
113
113
  cpmi?: number | null;
114
114
  cpi?: number | null;
115
115
  cpe?: number | null;
116
+ /**
117
+ * Alcance Potencial (o antigo PO Reach): seguidores no momento do post. Em
118
+ * Content.metrics soma o autor e todos os coautores. Ver GD-703.
119
+ */
120
+ potentialReach?: number | null;
121
+ /** Tx. de Engaj. por Alcance: engagementCount ÷ reachCount (fração). Ver GD-703. */
122
+ reachEngagementRate?: number | null;
123
+ /** Tx. de Engaj. por Seguidores: engagementCount ÷ potentialReach (fração). Ver GD-703. */
124
+ followersEngagementRate?: number | null;
116
125
  }
117
126
  export declare const MetricsSchema: MongooseSchema<Metrics, import("mongoose").Model<Metrics, any, any, any, import("mongoose").Document<unknown, any, Metrics, any, {}> & Metrics & {
118
127
  _id: Types.ObjectId;
@@ -123,6 +132,111 @@ export declare const MetricsSchema: MongooseSchema<Metrics, import("mongoose").M
123
132
  } & {
124
133
  __v: number;
125
134
  }>;
135
+ /**
136
+ * Métricas que dependem dos seguidores de uma pessoa do post (autor ou
137
+ * coautor). As brutas ficam uma vez só em Content.metrics. Ver GD-703.
138
+ */
139
+ export declare class ContentPersonMetrics {
140
+ potentialReach?: number | null;
141
+ followersEngagementRate?: number | null;
142
+ effectiveReach?: number | null;
143
+ reachPerformance?: number | null;
144
+ qualifiedEngagementRate?: number | null;
145
+ score?: number | null;
146
+ /** Nome do Client.tiers da pessoa; null sem Tiers ou fora das faixas. */
147
+ tier?: string | null;
148
+ /** reachEngagementRate ≥ meta viralReachEngagementRate do Tier; null sem Tier ou sem a meta. */
149
+ isViral?: boolean | null;
150
+ }
151
+ export declare const ContentPersonMetricsSchema: MongooseSchema<ContentPersonMetrics, import("mongoose").Model<ContentPersonMetrics, any, any, any, import("mongoose").Document<unknown, any, ContentPersonMetrics, any, {}> & ContentPersonMetrics & {
152
+ _id: Types.ObjectId;
153
+ } & {
154
+ __v: number;
155
+ }, any>, {}, {}, {}, {}, import("mongoose").DefaultSchemaOptions, ContentPersonMetrics, import("mongoose").Document<unknown, {}, import("mongoose").FlatRecord<ContentPersonMetrics>, {}, import("mongoose").ResolveSchemaOptions<import("mongoose").DefaultSchemaOptions>> & import("mongoose").FlatRecord<ContentPersonMetrics> & {
156
+ _id: Types.ObjectId;
157
+ } & {
158
+ __v: number;
159
+ }>;
160
+ /** Coautor do post (Colab), com os seguidores dele no momento do post. Ver GD-703. */
161
+ export declare class ContentCollab {
162
+ profile: Types.ObjectId | Profile;
163
+ followersAtPost?: number | null;
164
+ metrics?: ContentPersonMetrics | null;
165
+ }
166
+ export declare const ContentCollabSchema: MongooseSchema<ContentCollab, import("mongoose").Model<ContentCollab, any, any, any, import("mongoose").Document<unknown, any, ContentCollab, any, {}> & ContentCollab & {
167
+ _id: Types.ObjectId;
168
+ } & {
169
+ __v: number;
170
+ }, any>, {}, {}, {}, {}, import("mongoose").DefaultSchemaOptions, ContentCollab, import("mongoose").Document<unknown, {}, import("mongoose").FlatRecord<ContentCollab>, {}, import("mongoose").ResolveSchemaOptions<import("mongoose").DefaultSchemaOptions>> & import("mongoose").FlatRecord<ContentCollab> & {
171
+ _id: Types.ObjectId;
172
+ } & {
173
+ __v: number;
174
+ }>;
175
+ /** Chaves brutas de Metrics que têm parte Orgânica e Impulsionada. Ver GD-707. */
176
+ export declare const CONTENT_BASE_METRIC_KEYS: readonly ["impressionCount", "reachCount", "likeCount", "commentCount", "savedCount", "sharesCount", "storyCount", "tapBackCount", "clickCount", "replyCount", "tapForwardCount", "swipeForwardCount", "exitCount", "favoriteCount", "dislikeCount", "emv"];
177
+ export type ContentBaseMetricKey = (typeof CONTENT_BASE_METRIC_KEYS)[number];
178
+ export declare class ContentBaseMetrics {
179
+ impressionCount?: number | null;
180
+ reachCount?: number | null;
181
+ likeCount?: number | null;
182
+ commentCount?: number | null;
183
+ savedCount?: number | null;
184
+ sharesCount?: number | null;
185
+ storyCount?: number | null;
186
+ tapBackCount?: number | null;
187
+ clickCount?: number | null;
188
+ replyCount?: number | null;
189
+ tapForwardCount?: number | null;
190
+ swipeForwardCount?: number | null;
191
+ exitCount?: number | null;
192
+ favoriteCount?: number | null;
193
+ dislikeCount?: number | null;
194
+ emv?: number | null;
195
+ }
196
+ export declare const ContentBaseMetricsSchema: MongooseSchema<ContentBaseMetrics, import("mongoose").Model<ContentBaseMetrics, any, any, any, import("mongoose").Document<unknown, any, ContentBaseMetrics, any, {}> & ContentBaseMetrics & {
197
+ _id: Types.ObjectId;
198
+ } & {
199
+ __v: number;
200
+ }, any>, {}, {}, {}, {}, import("mongoose").DefaultSchemaOptions, ContentBaseMetrics, import("mongoose").Document<unknown, {}, import("mongoose").FlatRecord<ContentBaseMetrics>, {}, import("mongoose").ResolveSchemaOptions<import("mongoose").DefaultSchemaOptions>> & import("mongoose").FlatRecord<ContentBaseMetrics> & {
201
+ _id: Types.ObjectId;
202
+ } & {
203
+ __v: number;
204
+ }>;
205
+ /** Divisão Orgânico e Impulsionado; o total em Content.metrics é sempre a soma. Ver GD-707. */
206
+ export declare class ContentMetricsBreakdown {
207
+ organic: ContentBaseMetrics;
208
+ boosted: ContentBaseMetrics;
209
+ }
210
+ export declare const ContentMetricsBreakdownSchema: MongooseSchema<ContentMetricsBreakdown, import("mongoose").Model<ContentMetricsBreakdown, any, any, any, import("mongoose").Document<unknown, any, ContentMetricsBreakdown, any, {}> & ContentMetricsBreakdown & {
211
+ _id: Types.ObjectId;
212
+ } & {
213
+ __v: number;
214
+ }, any>, {}, {}, {}, {}, import("mongoose").DefaultSchemaOptions, ContentMetricsBreakdown, import("mongoose").Document<unknown, {}, import("mongoose").FlatRecord<ContentMetricsBreakdown>, {}, import("mongoose").ResolveSchemaOptions<import("mongoose").DefaultSchemaOptions>> & import("mongoose").FlatRecord<ContentMetricsBreakdown> & {
215
+ _id: Types.ObjectId;
216
+ } & {
217
+ __v: number;
218
+ }>;
219
+ /**
220
+ * Dados do vídeo para o VCR. Views não ficam aqui: impressões e views são
221
+ * sempre Content.metrics.impressionCount. Ver GD-707.
222
+ */
223
+ export declare class ContentVideo {
224
+ /** Duração em segundos. */
225
+ duration?: number | null;
226
+ /** Tempo total assistido, somando todas as visualizações, em segundos. */
227
+ watchTime?: number | null;
228
+ /** (watchTime ÷ duration) ÷ impressionCount, sem teto. */
229
+ vcr?: number | null;
230
+ }
231
+ export declare const ContentVideoSchema: MongooseSchema<ContentVideo, import("mongoose").Model<ContentVideo, any, any, any, import("mongoose").Document<unknown, any, ContentVideo, any, {}> & ContentVideo & {
232
+ _id: Types.ObjectId;
233
+ } & {
234
+ __v: number;
235
+ }, any>, {}, {}, {}, {}, import("mongoose").DefaultSchemaOptions, ContentVideo, import("mongoose").Document<unknown, {}, import("mongoose").FlatRecord<ContentVideo>, {}, import("mongoose").ResolveSchemaOptions<import("mongoose").DefaultSchemaOptions>> & import("mongoose").FlatRecord<ContentVideo> & {
236
+ _id: Types.ObjectId;
237
+ } & {
238
+ __v: number;
239
+ }>;
126
240
  export declare class ContentMetricsHistoryEntry {
127
241
  metrics: Metrics;
128
242
  recordedAt: Date;
@@ -195,8 +309,19 @@ export declare class Content {
195
309
  isCollab: boolean;
196
310
  isBoosted: boolean;
197
311
  hasAffiliateLink: boolean;
198
- collabs?: Types.ObjectId[] | Profile[];
312
+ /** Coautores do post; isCollab = collabs.length > 0. Ver GD-703. */
313
+ collabs?: ContentCollab[];
314
+ /** Métricas do autor, com os seguidores dele (followersAtPost). Ver GD-703. */
315
+ profileMetrics?: ContentPersonMetrics | null;
316
+ /** Visão da Ação: brutas uma vez só e derivadas com o potentialReach somado. */
199
317
  metrics: Metrics;
318
+ metricsBreakdown?: ContentMetricsBreakdown | null;
319
+ /**
320
+ * Caminhos das partes digitadas à mão (ex.: organic.reachCount,
321
+ * video.watchTime): a captura não sobrescreve. Ver GD-707.
322
+ */
323
+ manualMetrics?: string[];
324
+ video?: ContentVideo | null;
200
325
  estimates: Estimates;
201
326
  googleSheetRow?: string;
202
327
  createdAt?: Date;
@@ -206,7 +331,9 @@ export declare class Content {
206
331
  sentimentAnalysis?: SentimentAnalysis | null;
207
332
  thumbnailUrl?: string;
208
333
  mediaUrl?: string;
334
+ /** @deprecated Obsoleto desde a GD-707: a edição vale por parte (manualMetrics). */
209
335
  isEdited?: boolean;
336
+ /** @deprecated Obsoleto desde a GD-707. */
210
337
  editedAt?: Date;
211
338
  followersAtPost?: number | null;
212
339
  history?: ContentMetricsHistoryEntry[];
@@ -222,6 +349,12 @@ export declare class Content {
222
349
  /** _id de uma Action.phases. Ver GD-691. */
223
350
  phase?: Types.ObjectId | null;
224
351
  phaseSource?: ContentClassificationSource | null;
352
+ /**
353
+ * Link normalizado (normalizeContentLink) para achar o mesmo post em outras
354
+ * Ações. Preenchido pelo hook de save; quem troca o link por updateOne grava
355
+ * junto. Ver GD-699.
356
+ */
357
+ linkKey?: string | null;
225
358
  }
226
359
  export declare const ContentSchema: MongooseSchema<Content, import("mongoose").Model<Content, any, any, any, import("mongoose").Document<unknown, any, Content, any, {}> & Content & Required<{
227
360
  _id: Types.ObjectId;
@@ -9,9 +9,10 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.ContentModel = exports.ContentSchema = exports.Content = exports.ContentStatusChangedBySchema = exports.ContentStatusChangedBy = exports.CONTENT_CLASSIFICATION_SOURCE_VALUES = exports.CONTENT_METRICS_SOURCE_VALUES = exports.CONTENT_COMMENT_COUNT_SOURCE_VALUES = exports.CONTENT_REPORTABLE_STATUS_VALUES = exports.CONTENT_STATUS_VALUES = exports.EstimatesSchema = exports.Estimates = exports.ContentMetricsHistoryEntrySchema = exports.ContentMetricsHistoryEntry = exports.MetricsSchema = exports.Metrics = exports.SentimentAnalysisSchema = exports.SentimentAnalysis = exports.SentimentScoreSchema = exports.SentimentScore = exports.WordCloudSchema = exports.WordCloud = exports.SentimentCommentSchema = exports.SentimentComment = void 0;
12
+ exports.ContentModel = exports.ContentSchema = exports.Content = exports.ContentStatusChangedBySchema = exports.ContentStatusChangedBy = exports.CONTENT_CLASSIFICATION_SOURCE_VALUES = exports.CONTENT_METRICS_SOURCE_VALUES = exports.CONTENT_COMMENT_COUNT_SOURCE_VALUES = exports.CONTENT_REPORTABLE_STATUS_VALUES = exports.CONTENT_STATUS_VALUES = exports.EstimatesSchema = exports.Estimates = exports.ContentMetricsHistoryEntrySchema = exports.ContentMetricsHistoryEntry = exports.ContentVideoSchema = exports.ContentVideo = exports.ContentMetricsBreakdownSchema = exports.ContentMetricsBreakdown = exports.ContentBaseMetricsSchema = exports.ContentBaseMetrics = exports.CONTENT_BASE_METRIC_KEYS = exports.ContentCollabSchema = exports.ContentCollab = exports.ContentPersonMetricsSchema = exports.ContentPersonMetrics = exports.MetricsSchema = exports.Metrics = exports.SentimentAnalysisSchema = exports.SentimentAnalysis = exports.SentimentScoreSchema = exports.SentimentScore = exports.WordCloudSchema = exports.WordCloud = exports.SentimentCommentSchema = exports.SentimentComment = void 0;
13
13
  const mongoose_1 = require("@nestjs/mongoose");
14
14
  const mongoose_2 = require("mongoose");
15
+ const normalize_content_link_util_1 = require("../utils/normalize-content-link.util");
15
16
  let SentimentComment = class SentimentComment {
16
17
  };
17
18
  __decorate([
@@ -206,11 +207,215 @@ __decorate([
206
207
  (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
207
208
  __metadata("design:type", Object)
208
209
  ], Metrics.prototype, "cpe", void 0);
210
+ __decorate([
211
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
212
+ __metadata("design:type", Object)
213
+ ], Metrics.prototype, "potentialReach", void 0);
214
+ __decorate([
215
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
216
+ __metadata("design:type", Object)
217
+ ], Metrics.prototype, "reachEngagementRate", void 0);
218
+ __decorate([
219
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
220
+ __metadata("design:type", Object)
221
+ ], Metrics.prototype, "followersEngagementRate", void 0);
209
222
  Metrics = __decorate([
210
223
  (0, mongoose_1.Schema)({ _id: false })
211
224
  ], Metrics);
212
225
  exports.Metrics = Metrics;
213
226
  exports.MetricsSchema = mongoose_1.SchemaFactory.createForClass(Metrics);
227
+ /**
228
+ * Métricas que dependem dos seguidores de uma pessoa do post (autor ou
229
+ * coautor). As brutas ficam uma vez só em Content.metrics. Ver GD-703.
230
+ */
231
+ let ContentPersonMetrics = class ContentPersonMetrics {
232
+ };
233
+ __decorate([
234
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
235
+ __metadata("design:type", Object)
236
+ ], ContentPersonMetrics.prototype, "potentialReach", void 0);
237
+ __decorate([
238
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
239
+ __metadata("design:type", Object)
240
+ ], ContentPersonMetrics.prototype, "followersEngagementRate", void 0);
241
+ __decorate([
242
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
243
+ __metadata("design:type", Object)
244
+ ], ContentPersonMetrics.prototype, "effectiveReach", void 0);
245
+ __decorate([
246
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
247
+ __metadata("design:type", Object)
248
+ ], ContentPersonMetrics.prototype, "reachPerformance", void 0);
249
+ __decorate([
250
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
251
+ __metadata("design:type", Object)
252
+ ], ContentPersonMetrics.prototype, "qualifiedEngagementRate", void 0);
253
+ __decorate([
254
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
255
+ __metadata("design:type", Object)
256
+ ], ContentPersonMetrics.prototype, "score", void 0);
257
+ __decorate([
258
+ (0, mongoose_1.Prop)({ type: String, required: false, default: null }),
259
+ __metadata("design:type", Object)
260
+ ], ContentPersonMetrics.prototype, "tier", void 0);
261
+ __decorate([
262
+ (0, mongoose_1.Prop)({ type: Boolean, required: false, default: null }),
263
+ __metadata("design:type", Object)
264
+ ], ContentPersonMetrics.prototype, "isViral", void 0);
265
+ ContentPersonMetrics = __decorate([
266
+ (0, mongoose_1.Schema)({ _id: false })
267
+ ], ContentPersonMetrics);
268
+ exports.ContentPersonMetrics = ContentPersonMetrics;
269
+ exports.ContentPersonMetricsSchema = mongoose_1.SchemaFactory.createForClass(ContentPersonMetrics);
270
+ /** Coautor do post (Colab), com os seguidores dele no momento do post. Ver GD-703. */
271
+ let ContentCollab = class ContentCollab {
272
+ };
273
+ __decorate([
274
+ (0, mongoose_1.Prop)({ type: mongoose_2.Schema.Types.ObjectId, ref: 'Profile', required: true }),
275
+ __metadata("design:type", Object)
276
+ ], ContentCollab.prototype, "profile", void 0);
277
+ __decorate([
278
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
279
+ __metadata("design:type", Object)
280
+ ], ContentCollab.prototype, "followersAtPost", void 0);
281
+ __decorate([
282
+ (0, mongoose_1.Prop)({ type: exports.ContentPersonMetricsSchema, required: false, default: null }),
283
+ __metadata("design:type", Object)
284
+ ], ContentCollab.prototype, "metrics", void 0);
285
+ ContentCollab = __decorate([
286
+ (0, mongoose_1.Schema)({ _id: false })
287
+ ], ContentCollab);
288
+ exports.ContentCollab = ContentCollab;
289
+ exports.ContentCollabSchema = mongoose_1.SchemaFactory.createForClass(ContentCollab);
290
+ /** Chaves brutas de Metrics que têm parte Orgânica e Impulsionada. Ver GD-707. */
291
+ exports.CONTENT_BASE_METRIC_KEYS = [
292
+ 'impressionCount',
293
+ 'reachCount',
294
+ 'likeCount',
295
+ 'commentCount',
296
+ 'savedCount',
297
+ 'sharesCount',
298
+ 'storyCount',
299
+ 'tapBackCount',
300
+ 'clickCount',
301
+ 'replyCount',
302
+ 'tapForwardCount',
303
+ 'swipeForwardCount',
304
+ 'exitCount',
305
+ 'favoriteCount',
306
+ 'dislikeCount',
307
+ 'emv',
308
+ ];
309
+ let ContentBaseMetrics = class ContentBaseMetrics {
310
+ };
311
+ __decorate([
312
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
313
+ __metadata("design:type", Object)
314
+ ], ContentBaseMetrics.prototype, "impressionCount", void 0);
315
+ __decorate([
316
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
317
+ __metadata("design:type", Object)
318
+ ], ContentBaseMetrics.prototype, "reachCount", void 0);
319
+ __decorate([
320
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
321
+ __metadata("design:type", Object)
322
+ ], ContentBaseMetrics.prototype, "likeCount", void 0);
323
+ __decorate([
324
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
325
+ __metadata("design:type", Object)
326
+ ], ContentBaseMetrics.prototype, "commentCount", void 0);
327
+ __decorate([
328
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
329
+ __metadata("design:type", Object)
330
+ ], ContentBaseMetrics.prototype, "savedCount", void 0);
331
+ __decorate([
332
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
333
+ __metadata("design:type", Object)
334
+ ], ContentBaseMetrics.prototype, "sharesCount", void 0);
335
+ __decorate([
336
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
337
+ __metadata("design:type", Object)
338
+ ], ContentBaseMetrics.prototype, "storyCount", void 0);
339
+ __decorate([
340
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
341
+ __metadata("design:type", Object)
342
+ ], ContentBaseMetrics.prototype, "tapBackCount", void 0);
343
+ __decorate([
344
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
345
+ __metadata("design:type", Object)
346
+ ], ContentBaseMetrics.prototype, "clickCount", void 0);
347
+ __decorate([
348
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
349
+ __metadata("design:type", Object)
350
+ ], ContentBaseMetrics.prototype, "replyCount", void 0);
351
+ __decorate([
352
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
353
+ __metadata("design:type", Object)
354
+ ], ContentBaseMetrics.prototype, "tapForwardCount", void 0);
355
+ __decorate([
356
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
357
+ __metadata("design:type", Object)
358
+ ], ContentBaseMetrics.prototype, "swipeForwardCount", void 0);
359
+ __decorate([
360
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
361
+ __metadata("design:type", Object)
362
+ ], ContentBaseMetrics.prototype, "exitCount", void 0);
363
+ __decorate([
364
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
365
+ __metadata("design:type", Object)
366
+ ], ContentBaseMetrics.prototype, "favoriteCount", void 0);
367
+ __decorate([
368
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
369
+ __metadata("design:type", Object)
370
+ ], ContentBaseMetrics.prototype, "dislikeCount", void 0);
371
+ __decorate([
372
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
373
+ __metadata("design:type", Object)
374
+ ], ContentBaseMetrics.prototype, "emv", void 0);
375
+ ContentBaseMetrics = __decorate([
376
+ (0, mongoose_1.Schema)({ _id: false })
377
+ ], ContentBaseMetrics);
378
+ exports.ContentBaseMetrics = ContentBaseMetrics;
379
+ exports.ContentBaseMetricsSchema = mongoose_1.SchemaFactory.createForClass(ContentBaseMetrics);
380
+ /** Divisão Orgânico e Impulsionado; o total em Content.metrics é sempre a soma. Ver GD-707. */
381
+ let ContentMetricsBreakdown = class ContentMetricsBreakdown {
382
+ };
383
+ __decorate([
384
+ (0, mongoose_1.Prop)({ type: exports.ContentBaseMetricsSchema, required: false, default: () => ({}) }),
385
+ __metadata("design:type", ContentBaseMetrics)
386
+ ], ContentMetricsBreakdown.prototype, "organic", void 0);
387
+ __decorate([
388
+ (0, mongoose_1.Prop)({ type: exports.ContentBaseMetricsSchema, required: false, default: () => ({}) }),
389
+ __metadata("design:type", ContentBaseMetrics)
390
+ ], ContentMetricsBreakdown.prototype, "boosted", void 0);
391
+ ContentMetricsBreakdown = __decorate([
392
+ (0, mongoose_1.Schema)({ _id: false })
393
+ ], ContentMetricsBreakdown);
394
+ exports.ContentMetricsBreakdown = ContentMetricsBreakdown;
395
+ exports.ContentMetricsBreakdownSchema = mongoose_1.SchemaFactory.createForClass(ContentMetricsBreakdown);
396
+ /**
397
+ * Dados do vídeo para o VCR. Views não ficam aqui: impressões e views são
398
+ * sempre Content.metrics.impressionCount. Ver GD-707.
399
+ */
400
+ let ContentVideo = class ContentVideo {
401
+ };
402
+ __decorate([
403
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
404
+ __metadata("design:type", Object)
405
+ ], ContentVideo.prototype, "duration", void 0);
406
+ __decorate([
407
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
408
+ __metadata("design:type", Object)
409
+ ], ContentVideo.prototype, "watchTime", void 0);
410
+ __decorate([
411
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
412
+ __metadata("design:type", Object)
413
+ ], ContentVideo.prototype, "vcr", void 0);
414
+ ContentVideo = __decorate([
415
+ (0, mongoose_1.Schema)({ _id: false })
416
+ ], ContentVideo);
417
+ exports.ContentVideo = ContentVideo;
418
+ exports.ContentVideoSchema = mongoose_1.SchemaFactory.createForClass(ContentVideo);
214
419
  let ContentMetricsHistoryEntry = class ContentMetricsHistoryEntry {
215
420
  };
216
421
  __decorate([
@@ -390,15 +595,29 @@ __decorate([
390
595
  __metadata("design:type", Boolean)
391
596
  ], Content.prototype, "hasAffiliateLink", void 0);
392
597
  __decorate([
393
- (0, mongoose_1.Prop)([
394
- { type: mongoose_2.Schema.Types.ObjectId, ref: 'Profile', required: false },
395
- ]),
598
+ (0, mongoose_1.Prop)({ type: [exports.ContentCollabSchema], required: false, default: [] }),
396
599
  __metadata("design:type", Array)
397
600
  ], Content.prototype, "collabs", void 0);
601
+ __decorate([
602
+ (0, mongoose_1.Prop)({ type: exports.ContentPersonMetricsSchema, required: false, default: null }),
603
+ __metadata("design:type", Object)
604
+ ], Content.prototype, "profileMetrics", void 0);
398
605
  __decorate([
399
606
  (0, mongoose_1.Prop)({ type: exports.MetricsSchema, required: true }),
400
607
  __metadata("design:type", Metrics)
401
608
  ], Content.prototype, "metrics", void 0);
609
+ __decorate([
610
+ (0, mongoose_1.Prop)({ type: exports.ContentMetricsBreakdownSchema, required: false, default: null }),
611
+ __metadata("design:type", Object)
612
+ ], Content.prototype, "metricsBreakdown", void 0);
613
+ __decorate([
614
+ (0, mongoose_1.Prop)({ type: [String], required: false, default: [] }),
615
+ __metadata("design:type", Array)
616
+ ], Content.prototype, "manualMetrics", void 0);
617
+ __decorate([
618
+ (0, mongoose_1.Prop)({ type: exports.ContentVideoSchema, required: false, default: null }),
619
+ __metadata("design:type", Object)
620
+ ], Content.prototype, "video", void 0);
402
621
  __decorate([
403
622
  (0, mongoose_1.Prop)({ type: exports.MetricsSchema, required: true }),
404
623
  __metadata("design:type", Estimates)
@@ -471,11 +690,23 @@ __decorate([
471
690
  (0, mongoose_1.Prop)({ type: String, enum: exports.CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null }),
472
691
  __metadata("design:type", Object)
473
692
  ], Content.prototype, "phaseSource", void 0);
693
+ __decorate([
694
+ (0, mongoose_1.Prop)({ type: String, required: false, default: null }),
695
+ __metadata("design:type", Object)
696
+ ], Content.prototype, "linkKey", void 0);
474
697
  Content = __decorate([
475
698
  (0, mongoose_1.Schema)({ timestamps: true })
476
699
  ], Content);
477
700
  exports.Content = Content;
478
701
  exports.ContentSchema = mongoose_1.SchemaFactory.createForClass(Content);
702
+ // Mantém o linkKey em todo create/save (captura, create-content, scripts com o
703
+ // schema). updateOne/findOneAndUpdate não passam por aqui.
704
+ exports.ContentSchema.pre('save', function (next) {
705
+ if (this.isNew || this.isModified('link')) {
706
+ this.linkKey = (0, normalize_content_link_util_1.normalizeContentLink)(this.link);
707
+ }
708
+ next();
709
+ });
479
710
  exports.ContentSchema.index({ action: 1, status: 1 });
480
711
  exports.ContentSchema.index({ action: 1, profile: 1, type: 1 });
481
712
  exports.ContentSchema.index({ action: 1, profile: 1, socialnetwork: 1 });
@@ -484,6 +715,11 @@ exports.ContentSchema.index({ profile: 1 });
484
715
  exports.ContentSchema.index({ action: 1, phase: 1 });
485
716
  exports.ContentSchema.index({ action: 1, isOrganic: 1 });
486
717
  exports.ContentSchema.index({ createdAt: -1, status: 1 });
718
+ // Trava de aprovação entre Ações (GD-699): busca o mesmo post por publicação ou link.
719
+ exports.ContentSchema.index({ primetagId: 1 });
720
+ exports.ContentSchema.index({ linkKey: 1 });
721
+ // Visão por Influenciador (GD-703): posts em que o Perfil é coautor.
722
+ exports.ContentSchema.index({ 'collabs.profile': 1 });
487
723
  // Chave de idempotência da captura da Primetag — conteúdos manuais (sem
488
724
  // primetagId) ficam fora do índice.
489
725
  exports.ContentSchema.index({ action: 1, primetagId: 1 }, { unique: true, partialFilterExpression: { primetagId: { $type: 'string' } } });
@@ -0,0 +1,12 @@
1
+ import { ContentMetricsBreakdown, Metrics } from '../schemas/content.schema';
2
+ /**
3
+ * Totais de Content.metrics a partir da divisão Orgânico e Impulsionado. Ver GD-707.
4
+ *
5
+ * - metrics.<chave> = organic + boosted (null só se as duas forem null);
6
+ * - engagementCount = o da Primetag, a menos que alguma parte de curtidas,
7
+ * comentários, compartilhamentos ou salvos seja manual: aí vira a soma dos totais;
8
+ * - engagementRate = engagementCount ÷ impressionCount.
9
+ *
10
+ * Usada pela captura e pela edição (api-campaigns): as duas precisam dar o mesmo total.
11
+ */
12
+ export declare const mergeMetricsBreakdown: (breakdown: ContentMetricsBreakdown, primetagEngagementCount: number | null | undefined, manualMetrics?: string[]) => Partial<Metrics>;
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mergeMetricsBreakdown = void 0;
4
+ const content_schema_1 = require("../schemas/content.schema");
5
+ // Partes que, se digitadas à mão, fazem o engajamento virar a soma.
6
+ const ENGAGEMENT_KEYS = ['likeCount', 'commentCount', 'sharesCount', 'savedCount'];
7
+ const sumParts = (organic, boosted) => organic == null && boosted == null ? null : (organic ?? 0) + (boosted ?? 0);
8
+ /**
9
+ * Totais de Content.metrics a partir da divisão Orgânico e Impulsionado. Ver GD-707.
10
+ *
11
+ * - metrics.<chave> = organic + boosted (null só se as duas forem null);
12
+ * - engagementCount = o da Primetag, a menos que alguma parte de curtidas,
13
+ * comentários, compartilhamentos ou salvos seja manual: aí vira a soma dos totais;
14
+ * - engagementRate = engagementCount ÷ impressionCount.
15
+ *
16
+ * Usada pela captura e pela edição (api-campaigns): as duas precisam dar o mesmo total.
17
+ */
18
+ const mergeMetricsBreakdown = (breakdown, primetagEngagementCount, manualMetrics = []) => {
19
+ const organic = breakdown.organic ?? {};
20
+ const boosted = breakdown.boosted ?? {};
21
+ const totals = {};
22
+ for (const key of content_schema_1.CONTENT_BASE_METRIC_KEYS) {
23
+ totals[key] = sumParts(organic[key], boosted[key]);
24
+ }
25
+ const hasManualEngagement = ENGAGEMENT_KEYS.some((key) => manualMetrics.includes(`organic.${key}`) || manualMetrics.includes(`boosted.${key}`));
26
+ const engagementCount = hasManualEngagement
27
+ ? ENGAGEMENT_KEYS.reduce((sum, key) => sum + (totals[key] ?? 0), 0)
28
+ : primetagEngagementCount ?? null;
29
+ const impressionCount = totals.impressionCount ?? 0;
30
+ return {
31
+ ...totals,
32
+ engagementCount,
33
+ engagementRate: engagementCount != null && impressionCount > 0 ? engagementCount / impressionCount : null,
34
+ };
35
+ };
36
+ exports.mergeMetricsBreakdown = mergeMetricsBreakdown;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Link do post sem protocolo, www./m., query, âncora e barra final. Mantém
3
+ * maiúsculas: o código do post (ex.: /reel/DCx8kQ2) diferencia. É a chave para
4
+ * achar o mesmo post em outras Ações (Content.linkKey). Ver GD-699.
5
+ *
6
+ * - YouTube `watch` mantém o `v` (o id do vídeo está na query);
7
+ * - link de perfil (um segmento só, ex.: instagram.com/usuario) e Drive: null;
8
+ * - null também para link vazio.
9
+ *
10
+ * A mesma regra está em scripts/lib/normalize-content-link.js: as duas mudam juntas.
11
+ */
12
+ export declare const normalizeContentLink: (link?: string | null) => string | null;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeContentLink = void 0;
4
+ // Hosts cujo link não é de um post (ex.: pasta do Drive com os stories): não
5
+ // geram chave, senão posts diferentes casariam pelo mesmo link.
6
+ const NON_POST_HOSTS = ['drive.google.com', 'docs.google.com'];
7
+ /**
8
+ * Link do post sem protocolo, www./m., query, âncora e barra final. Mantém
9
+ * maiúsculas: o código do post (ex.: /reel/DCx8kQ2) diferencia. É a chave para
10
+ * achar o mesmo post em outras Ações (Content.linkKey). Ver GD-699.
11
+ *
12
+ * - YouTube `watch` mantém o `v` (o id do vídeo está na query);
13
+ * - link de perfil (um segmento só, ex.: instagram.com/usuario) e Drive: null;
14
+ * - null também para link vazio.
15
+ *
16
+ * A mesma regra está em scripts/lib/normalize-content-link.js: as duas mudam juntas.
17
+ */
18
+ const normalizeContentLink = (link) => {
19
+ const raw = String(link ?? '')
20
+ .trim()
21
+ .replace(/^https?:\/\/(www\.|m\.)?/i, '');
22
+ const clean = raw.replace(/[?#].*$/, '').replace(/\/+$/, '');
23
+ const [host, ...segments] = clean.split('/');
24
+ if (!host || NON_POST_HOSTS.includes(host.toLowerCase()))
25
+ return null;
26
+ if (/^youtube\.com$/i.test(host) && segments[0] === 'watch') {
27
+ const videoId = raw.match(/[?&]v=([^&#]+)/)?.[1];
28
+ return videoId ? `${host}/watch?v=${videoId}` : null;
29
+ }
30
+ const minSegments = /^youtu\.be$/i.test(host) ? 1 : 2;
31
+ return segments.filter(Boolean).length >= minSegments ? clean : null;
32
+ };
33
+ exports.normalizeContentLink = normalizeContentLink;
@@ -0,0 +1,27 @@
1
+ # Code Review: shared-schemas
2
+
3
+ Checklist de cada PR, junto da spec da Task e da `docs/constitution.md`. Usado pela skill `gd-code-review`.
4
+
5
+ ## 1. Spec e commits
6
+
7
+ - [ ] A spec existe em `docs/specs/` (ou na API que consome) com `**Feature:** GD-xxx`?
8
+ - [ ] O schema bate com o contrato da spec?
9
+ - [ ] Cada commit segue a seção Commits da constitution? Para cada desvio, anotar como foi feito e como deveria ser.
10
+
11
+ ## 2. Compatibilidade
12
+
13
+ - [ ] Campo novo é opcional no tipo e tem `default`? Documento antigo continua válido?
14
+ - [ ] Nenhum campo foi removido, renomeado ou mudou de tipo sem migração e sem a lista de repos afetados?
15
+ - [ ] Enum novo segue o padrão `*_VALUES as const` + tipo derivado e está no barrel?
16
+ - [ ] Índice novo faz sentido para as queries de quem consome?
17
+
18
+ ## 3. Padrões
19
+
20
+ - [ ] `@ApiProperty` em todo campo exposto?
21
+ - [ ] JSDoc em português nos campos com regra?
22
+ - [ ] Sem helper de regra de negócio de uma API só?
23
+
24
+ ## 4. Publicação
25
+
26
+ - [ ] A versão no `package.json` é a próxima livre no npm?
27
+ - [ ] `npm run build` passa?
@@ -0,0 +1,75 @@
1
+ # Constitution: shared-schemas
2
+
3
+ Regras do pacote `@grapadigital/shared-schemas`: os schemas Mongoose de todas as entidades, consumidos pelas APIs e pelos workers. Os fronts não dependem deste pacote (ele carrega mongoose e decorators do Nest); os tipos do front ficam no `@grapadigital/shared-app-modules`.
4
+
5
+ ## Stack
6
+
7
+ - TypeScript compilado com `tsc` para CommonJS (`dist/`)
8
+ - Mongoose 8 + `@nestjs/mongoose` (`@Schema`, `@Prop`, `SchemaFactory`) e `@nestjs/swagger` (`@ApiProperty`)
9
+ - npm para publicar (`prepublishOnly` roda o build); sem lint nem testes configurados
10
+ - Prettier (`singleQuote: true`, `trailingComma: 'all'`)
11
+
12
+ ## Estrutura
13
+
14
+ - Um arquivo por entidade em `src/schemas/<entidade>.schema.ts`, com a classe, o `<Entidade>Schema`, os índices e o model.
15
+ - Barrel em `src/index.ts`, com `export * from './schemas/<entidade>.schema'`. Nome que conflita entre arquivos é exportado nominalmente (ex.: `note.schema`).
16
+ - Subdocumento é uma classe com `@Schema({ _id: false })` no mesmo arquivo da entidade dona. Usar `@Schema()` (com `_id`) só quando outro documento aponta para ele (ex.: `ActionPhase`).
17
+
18
+ ## Regras dos schemas
19
+
20
+ - **Enum** é uma constante `as const` exportada com o tipo derivado, usada no `@Prop` e no `@ApiProperty`:
21
+ ```ts
22
+ export const ENTIDADE_STATUS_VALUES = ['a', 'b'] as const;
23
+ export type EntidadeStatus = (typeof ENTIDADE_STATUS_VALUES)[number];
24
+ ```
25
+ - **Campo novo** é opcional no tipo (`campo?: T`) e tem `default` no `@Prop` (vazio ou `null`). Documento que já existe no banco não pode ficar inválido, e código que monta a entidade sem o campo não pode quebrar.
26
+ - **Todo campo exposto** tem `@ApiProperty`, para aparecer no Swagger das APIs.
27
+ - **JSDoc em português** em campo cuja regra não é óbvia: o que é, o que vazio significa e a Task (`Ver GD-xxx`).
28
+ - **Percentual** é gravado como fração (0,10 = 10%).
29
+ - **Nada de helper com regra de negócio** de uma API só: o pacote tem schemas, enums, tipos e constantes. Helper puro, sem I/O, usado por mais de um backend pode entrar.
30
+ - **Índice** novo é declarado no schema (`<Entidade>Schema.index(...)`); os serviços sobem com `autoIndex`.
31
+
32
+ ## Versão e publicação
33
+
34
+ 1. Subir a versão no `package.json` (conferir a última no npm: `npm view @grapadigital/shared-schemas version`).
35
+ 2. `npm run build` e checar o `dist`.
36
+ 3. Publicar (`npm publish`) e só então subir a dependência nas APIs e nos workers.
37
+
38
+ Mudança que quebra quem consome (remover ou renomear campo, mudar tipo) precisa de migração de dados e da lista de repos afetados na spec.
39
+
40
+ ## Spec antes do código
41
+
42
+ Nenhuma implementação começa sem spec. A spec fica em `docs/specs/<slice>.md`, com `**Feature:** GD-xxx`, e segue o formato da seção 6 do `AGENTS.md` da raiz do workspace (fluxo `gd-spec-task` → `gd-execute-spec` → `gd-code-review`).
43
+
44
+ ## Commits
45
+
46
+ Formato Conventional Commits: `<tipo>(<escopo>): <descrição>`.
47
+
48
+ | Tipo | Quando usar |
49
+ |---|---|
50
+ | `feat` | Nova feature ou novo slice |
51
+ | `fix` | Correção de bug |
52
+ | `refactor` | Refatoração sem mudança de comportamento |
53
+ | `docs` | Documentação (`docs/`, `AGENTS.md`, `README`) |
54
+ | `test` | Testes |
55
+ | `chore` | Dependências, configs, scripts |
56
+ | `perf` | Performance |
57
+
58
+ - Descrição em inglês, no imperativo, em minúsculas, numa linha só de até 72 caracteres.
59
+ - O escopo é a entidade ou o módulo afetado (ex.: `feat(client): add create-client slice`).
60
+ - Um commit é uma mudança coesa. Commitar só os arquivos da Task.
61
+ - Sem trailer de co-autoria.
62
+ - Nunca commitar `console.log`, `debugger`, código comentado, `.env` ou segredo.
63
+
64
+ ## Branch, PR e push
65
+
66
+ - Task de feature ou bug: branch a partir da `main` atualizada, no formato `<tipo>/GD-xxx-<descricao-curta>` (kebab-case, sem acento), e PR para a `main` com título e descrição em português e o link da Task.
67
+ - Antes do push:
68
+
69
+ - [ ] `npm run build` (tsc) compila sem erro
70
+ - [ ] campo novo é opcional ou tem default, e documentos antigos continuam válidos (`validateSync` com o `dist`)
71
+ - [ ] constante e tipo novos exportados pelo barrel (`src/index.ts`)
72
+ - [ ] versão nova no `package.json`, conferida no npm antes de publicar
73
+ - [ ] a spec existe em `docs/specs/` e bate com o que foi feito
74
+ - [ ] os commits seguem a seção Commits
75
+ - [ ] sem conflito com a `main`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grapadigital/shared-schemas",
3
- "version": "1.0.220",
3
+ "version": "1.0.222",
4
4
  "description": "Shared Mongoose Schemas",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -36,3 +36,5 @@ export * from './schemas/export-comments-job.schema';
36
36
  export * from './schemas/job.schema';
37
37
  export * from './schemas/snapshot.schema';
38
38
  export * from './schemas/lead.schema';
39
+ export * from './utils/normalize-content-link.util';
40
+ export * from './utils/merge-metrics-breakdown.util';
@@ -149,9 +149,10 @@ export class Action {
149
149
  @Prop({ type: String, enum: ['partner', 'exclusive'], required: true })
150
150
  partnership: 'partner' | 'exclusive';
151
151
 
152
- @ApiProperty({ enum: ['seeding', 'ugc', 'creator'], example: 'seeding' })
153
- @Prop({ type: String, enum: ['seeding', 'ugc', 'creator'], required: true })
154
- type: 'seeding' | 'ugc' | 'creator';
152
+ /** organic = Orgânicos do mês: post aprovado numa Ação de produto sai dela. Ver GD-699. */
153
+ @ApiProperty({ enum: ['seeding', 'ugc', 'creator', 'organic'], example: 'seeding' })
154
+ @Prop({ type: String, enum: ['seeding', 'ugc', 'creator', 'organic'], required: true })
155
+ type: 'seeding' | 'ugc' | 'creator' | 'organic';
155
156
 
156
157
  @ApiProperty({ example: ['instagram', 'tiktok'], required: false })
157
158
  @Prop({ type: [String], required: false })
@@ -7,6 +7,7 @@ import {
7
7
  } from 'mongoose';
8
8
  import { Profile } from './profile.schema';
9
9
  import { Action } from './action.schema';
10
+ import { normalizeContentLink } from '../utils/normalize-content-link.util';
10
11
 
11
12
  export type ContentDocument = HydratedDocument<Content>;
12
13
 
@@ -156,10 +157,182 @@ export class Metrics {
156
157
 
157
158
  @Prop({ type: Number, required: false, default: null })
158
159
  cpe?: number | null;
160
+
161
+ /**
162
+ * Alcance Potencial (o antigo PO Reach): seguidores no momento do post. Em
163
+ * Content.metrics soma o autor e todos os coautores. Ver GD-703.
164
+ */
165
+ @Prop({ type: Number, required: false, default: null })
166
+ potentialReach?: number | null;
167
+
168
+ /** Tx. de Engaj. por Alcance: engagementCount ÷ reachCount (fração). Ver GD-703. */
169
+ @Prop({ type: Number, required: false, default: null })
170
+ reachEngagementRate?: number | null;
171
+
172
+ /** Tx. de Engaj. por Seguidores: engagementCount ÷ potentialReach (fração). Ver GD-703. */
173
+ @Prop({ type: Number, required: false, default: null })
174
+ followersEngagementRate?: number | null;
159
175
  }
160
176
 
161
177
  export const MetricsSchema = SchemaFactory.createForClass(Metrics);
162
178
 
179
+ /**
180
+ * Métricas que dependem dos seguidores de uma pessoa do post (autor ou
181
+ * coautor). As brutas ficam uma vez só em Content.metrics. Ver GD-703.
182
+ */
183
+ @Schema({ _id: false })
184
+ export class ContentPersonMetrics {
185
+ @Prop({ type: Number, required: false, default: null })
186
+ potentialReach?: number | null;
187
+
188
+ @Prop({ type: Number, required: false, default: null })
189
+ followersEngagementRate?: number | null;
190
+
191
+ @Prop({ type: Number, required: false, default: null })
192
+ effectiveReach?: number | null;
193
+
194
+ @Prop({ type: Number, required: false, default: null })
195
+ reachPerformance?: number | null;
196
+
197
+ @Prop({ type: Number, required: false, default: null })
198
+ qualifiedEngagementRate?: number | null;
199
+
200
+ @Prop({ type: Number, required: false, default: null })
201
+ score?: number | null;
202
+
203
+ /** Nome do Client.tiers da pessoa; null sem Tiers ou fora das faixas. */
204
+ @Prop({ type: String, required: false, default: null })
205
+ tier?: string | null;
206
+
207
+ /** reachEngagementRate ≥ meta viralReachEngagementRate do Tier; null sem Tier ou sem a meta. */
208
+ @Prop({ type: Boolean, required: false, default: null })
209
+ isViral?: boolean | null;
210
+ }
211
+
212
+ export const ContentPersonMetricsSchema = SchemaFactory.createForClass(ContentPersonMetrics);
213
+
214
+ /** Coautor do post (Colab), com os seguidores dele no momento do post. Ver GD-703. */
215
+ @Schema({ _id: false })
216
+ export class ContentCollab {
217
+ @Prop({ type: MongooseSchema.Types.ObjectId, ref: 'Profile', required: true })
218
+ profile: Types.ObjectId | Profile;
219
+
220
+ @Prop({ type: Number, required: false, default: null })
221
+ followersAtPost?: number | null;
222
+
223
+ @Prop({ type: ContentPersonMetricsSchema, required: false, default: null })
224
+ metrics?: ContentPersonMetrics | null;
225
+ }
226
+
227
+ export const ContentCollabSchema = SchemaFactory.createForClass(ContentCollab);
228
+
229
+ /** Chaves brutas de Metrics que têm parte Orgânica e Impulsionada. Ver GD-707. */
230
+ export const CONTENT_BASE_METRIC_KEYS = [
231
+ 'impressionCount',
232
+ 'reachCount',
233
+ 'likeCount',
234
+ 'commentCount',
235
+ 'savedCount',
236
+ 'sharesCount',
237
+ 'storyCount',
238
+ 'tapBackCount',
239
+ 'clickCount',
240
+ 'replyCount',
241
+ 'tapForwardCount',
242
+ 'swipeForwardCount',
243
+ 'exitCount',
244
+ 'favoriteCount',
245
+ 'dislikeCount',
246
+ 'emv',
247
+ ] as const;
248
+ export type ContentBaseMetricKey = (typeof CONTENT_BASE_METRIC_KEYS)[number];
249
+
250
+ @Schema({ _id: false })
251
+ export class ContentBaseMetrics {
252
+ @Prop({ type: Number, required: false, default: null })
253
+ impressionCount?: number | null;
254
+
255
+ @Prop({ type: Number, required: false, default: null })
256
+ reachCount?: number | null;
257
+
258
+ @Prop({ type: Number, required: false, default: null })
259
+ likeCount?: number | null;
260
+
261
+ @Prop({ type: Number, required: false, default: null })
262
+ commentCount?: number | null;
263
+
264
+ @Prop({ type: Number, required: false, default: null })
265
+ savedCount?: number | null;
266
+
267
+ @Prop({ type: Number, required: false, default: null })
268
+ sharesCount?: number | null;
269
+
270
+ @Prop({ type: Number, required: false, default: null })
271
+ storyCount?: number | null;
272
+
273
+ @Prop({ type: Number, required: false, default: null })
274
+ tapBackCount?: number | null;
275
+
276
+ @Prop({ type: Number, required: false, default: null })
277
+ clickCount?: number | null;
278
+
279
+ @Prop({ type: Number, required: false, default: null })
280
+ replyCount?: number | null;
281
+
282
+ @Prop({ type: Number, required: false, default: null })
283
+ tapForwardCount?: number | null;
284
+
285
+ @Prop({ type: Number, required: false, default: null })
286
+ swipeForwardCount?: number | null;
287
+
288
+ @Prop({ type: Number, required: false, default: null })
289
+ exitCount?: number | null;
290
+
291
+ @Prop({ type: Number, required: false, default: null })
292
+ favoriteCount?: number | null;
293
+
294
+ @Prop({ type: Number, required: false, default: null })
295
+ dislikeCount?: number | null;
296
+
297
+ @Prop({ type: Number, required: false, default: null })
298
+ emv?: number | null;
299
+ }
300
+
301
+ export const ContentBaseMetricsSchema = SchemaFactory.createForClass(ContentBaseMetrics);
302
+
303
+ /** Divisão Orgânico e Impulsionado; o total em Content.metrics é sempre a soma. Ver GD-707. */
304
+ @Schema({ _id: false })
305
+ export class ContentMetricsBreakdown {
306
+ @Prop({ type: ContentBaseMetricsSchema, required: false, default: () => ({}) })
307
+ organic: ContentBaseMetrics;
308
+
309
+ @Prop({ type: ContentBaseMetricsSchema, required: false, default: () => ({}) })
310
+ boosted: ContentBaseMetrics;
311
+ }
312
+
313
+ export const ContentMetricsBreakdownSchema = SchemaFactory.createForClass(ContentMetricsBreakdown);
314
+
315
+ /**
316
+ * Dados do vídeo para o VCR. Views não ficam aqui: impressões e views são
317
+ * sempre Content.metrics.impressionCount. Ver GD-707.
318
+ */
319
+ @Schema({ _id: false })
320
+ export class ContentVideo {
321
+ /** Duração em segundos. */
322
+ @Prop({ type: Number, required: false, default: null })
323
+ duration?: number | null;
324
+
325
+ /** Tempo total assistido, somando todas as visualizações, em segundos. */
326
+ @Prop({ type: Number, required: false, default: null })
327
+ watchTime?: number | null;
328
+
329
+ /** (watchTime ÷ duration) ÷ impressionCount, sem teto. */
330
+ @Prop({ type: Number, required: false, default: null })
331
+ vcr?: number | null;
332
+ }
333
+
334
+ export const ContentVideoSchema = SchemaFactory.createForClass(ContentVideo);
335
+
163
336
  @Schema({ _id: false })
164
337
  export class ContentMetricsHistoryEntry {
165
338
  @Prop({ type: MetricsSchema, required: true })
@@ -329,14 +502,31 @@ export class Content {
329
502
  @Prop({ type: Boolean, required: true, default: false })
330
503
  hasAffiliateLink: boolean;
331
504
 
332
- @Prop([
333
- { type: MongooseSchema.Types.ObjectId, ref: 'Profile', required: false },
334
- ])
335
- collabs?: Types.ObjectId[] | Profile[];
505
+ /** Coautores do post; isCollab = collabs.length > 0. Ver GD-703. */
506
+ @Prop({ type: [ContentCollabSchema], required: false, default: [] })
507
+ collabs?: ContentCollab[];
336
508
 
509
+ /** Métricas do autor, com os seguidores dele (followersAtPost). Ver GD-703. */
510
+ @Prop({ type: ContentPersonMetricsSchema, required: false, default: null })
511
+ profileMetrics?: ContentPersonMetrics | null;
512
+
513
+ /** Visão da Ação: brutas uma vez só e derivadas com o potentialReach somado. */
337
514
  @Prop({ type: MetricsSchema, required: true })
338
515
  metrics: Metrics;
339
516
 
517
+ @Prop({ type: ContentMetricsBreakdownSchema, required: false, default: null })
518
+ metricsBreakdown?: ContentMetricsBreakdown | null;
519
+
520
+ /**
521
+ * Caminhos das partes digitadas à mão (ex.: organic.reachCount,
522
+ * video.watchTime): a captura não sobrescreve. Ver GD-707.
523
+ */
524
+ @Prop({ type: [String], required: false, default: [] })
525
+ manualMetrics?: string[];
526
+
527
+ @Prop({ type: ContentVideoSchema, required: false, default: null })
528
+ video?: ContentVideo | null;
529
+
340
530
  @Prop({ type: MetricsSchema, required: true })
341
531
  estimates: Estimates;
342
532
 
@@ -367,9 +557,11 @@ export class Content {
367
557
  @Prop({ type: String, required: false })
368
558
  mediaUrl?: string;
369
559
 
560
+ /** @deprecated Obsoleto desde a GD-707: a edição vale por parte (manualMetrics). */
370
561
  @Prop({ type: Boolean, required: false, default: false })
371
562
  isEdited?: boolean;
372
563
 
564
+ /** @deprecated Obsoleto desde a GD-707. */
373
565
  @Prop({ type: Date, required: false })
374
566
  editedAt?: Date;
375
567
 
@@ -404,10 +596,27 @@ export class Content {
404
596
 
405
597
  @Prop({ type: String, enum: CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null })
406
598
  phaseSource?: ContentClassificationSource | null;
599
+
600
+ /**
601
+ * Link normalizado (normalizeContentLink) para achar o mesmo post em outras
602
+ * Ações. Preenchido pelo hook de save; quem troca o link por updateOne grava
603
+ * junto. Ver GD-699.
604
+ */
605
+ @Prop({ type: String, required: false, default: null })
606
+ linkKey?: string | null;
407
607
  }
408
608
 
409
609
  export const ContentSchema = SchemaFactory.createForClass(Content);
410
610
 
611
+ // Mantém o linkKey em todo create/save (captura, create-content, scripts com o
612
+ // schema). updateOne/findOneAndUpdate não passam por aqui.
613
+ ContentSchema.pre('save', function (next) {
614
+ if (this.isNew || this.isModified('link')) {
615
+ this.linkKey = normalizeContentLink(this.link);
616
+ }
617
+ next();
618
+ });
619
+
411
620
  ContentSchema.index({ action: 1, status: 1 });
412
621
  ContentSchema.index({ action: 1, profile: 1, type: 1 });
413
622
  ContentSchema.index({ action: 1, profile: 1, socialnetwork: 1 });
@@ -416,6 +625,11 @@ ContentSchema.index({ profile: 1 });
416
625
  ContentSchema.index({ action: 1, phase: 1 });
417
626
  ContentSchema.index({ action: 1, isOrganic: 1 });
418
627
  ContentSchema.index({ createdAt: -1, status: 1 });
628
+ // Trava de aprovação entre Ações (GD-699): busca o mesmo post por publicação ou link.
629
+ ContentSchema.index({ primetagId: 1 });
630
+ ContentSchema.index({ linkKey: 1 });
631
+ // Visão por Influenciador (GD-703): posts em que o Perfil é coautor.
632
+ ContentSchema.index({ 'collabs.profile': 1 });
419
633
  // Chave de idempotência da captura da Primetag — conteúdos manuais (sem
420
634
  // primetagId) ficam fora do índice.
421
635
  ContentSchema.index(
@@ -0,0 +1,51 @@
1
+ import {
2
+ CONTENT_BASE_METRIC_KEYS,
3
+ ContentBaseMetricKey,
4
+ ContentBaseMetrics,
5
+ ContentMetricsBreakdown,
6
+ Metrics,
7
+ } from '../schemas/content.schema';
8
+
9
+ // Partes que, se digitadas à mão, fazem o engajamento virar a soma.
10
+ const ENGAGEMENT_KEYS: ContentBaseMetricKey[] = ['likeCount', 'commentCount', 'sharesCount', 'savedCount'];
11
+
12
+ const sumParts = (organic?: number | null, boosted?: number | null): number | null =>
13
+ organic == null && boosted == null ? null : (organic ?? 0) + (boosted ?? 0);
14
+
15
+ /**
16
+ * Totais de Content.metrics a partir da divisão Orgânico e Impulsionado. Ver GD-707.
17
+ *
18
+ * - metrics.<chave> = organic + boosted (null só se as duas forem null);
19
+ * - engagementCount = o da Primetag, a menos que alguma parte de curtidas,
20
+ * comentários, compartilhamentos ou salvos seja manual: aí vira a soma dos totais;
21
+ * - engagementRate = engagementCount ÷ impressionCount.
22
+ *
23
+ * Usada pela captura e pela edição (api-campaigns): as duas precisam dar o mesmo total.
24
+ */
25
+ export const mergeMetricsBreakdown = (
26
+ breakdown: ContentMetricsBreakdown,
27
+ primetagEngagementCount: number | null | undefined,
28
+ manualMetrics: string[] = [],
29
+ ): Partial<Metrics> => {
30
+ const organic: ContentBaseMetrics = breakdown.organic ?? {};
31
+ const boosted: ContentBaseMetrics = breakdown.boosted ?? {};
32
+
33
+ const totals: Partial<Metrics> = {};
34
+ for (const key of CONTENT_BASE_METRIC_KEYS) {
35
+ totals[key] = sumParts(organic[key], boosted[key]);
36
+ }
37
+
38
+ const hasManualEngagement = ENGAGEMENT_KEYS.some(
39
+ (key) => manualMetrics.includes(`organic.${key}`) || manualMetrics.includes(`boosted.${key}`),
40
+ );
41
+ const engagementCount = hasManualEngagement
42
+ ? ENGAGEMENT_KEYS.reduce((sum, key) => sum + (totals[key] ?? 0), 0)
43
+ : primetagEngagementCount ?? null;
44
+
45
+ const impressionCount = totals.impressionCount ?? 0;
46
+ return {
47
+ ...totals,
48
+ engagementCount,
49
+ engagementRate: engagementCount != null && impressionCount > 0 ? engagementCount / impressionCount : null,
50
+ };
51
+ };
@@ -0,0 +1,31 @@
1
+ // Hosts cujo link não é de um post (ex.: pasta do Drive com os stories): não
2
+ // geram chave, senão posts diferentes casariam pelo mesmo link.
3
+ const NON_POST_HOSTS = ['drive.google.com', 'docs.google.com'];
4
+
5
+ /**
6
+ * Link do post sem protocolo, www./m., query, âncora e barra final. Mantém
7
+ * maiúsculas: o código do post (ex.: /reel/DCx8kQ2) diferencia. É a chave para
8
+ * achar o mesmo post em outras Ações (Content.linkKey). Ver GD-699.
9
+ *
10
+ * - YouTube `watch` mantém o `v` (o id do vídeo está na query);
11
+ * - link de perfil (um segmento só, ex.: instagram.com/usuario) e Drive: null;
12
+ * - null também para link vazio.
13
+ *
14
+ * A mesma regra está em scripts/lib/normalize-content-link.js: as duas mudam juntas.
15
+ */
16
+ export const normalizeContentLink = (link?: string | null): string | null => {
17
+ const raw = String(link ?? '')
18
+ .trim()
19
+ .replace(/^https?:\/\/(www\.|m\.)?/i, '');
20
+ const clean = raw.replace(/[?#].*$/, '').replace(/\/+$/, '');
21
+ const [host, ...segments] = clean.split('/');
22
+ if (!host || NON_POST_HOSTS.includes(host.toLowerCase())) return null;
23
+
24
+ if (/^youtube\.com$/i.test(host) && segments[0] === 'watch') {
25
+ const videoId = raw.match(/[?&]v=([^&#]+)/)?.[1];
26
+ return videoId ? `${host}/watch?v=${videoId}` : null;
27
+ }
28
+
29
+ const minSegments = /^youtu\.be$/i.test(host) ? 1 : 2;
30
+ return segments.filter(Boolean).length >= minSegments ? clean : null;
31
+ };