@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 +2 -0
- package/dist/index.js +2 -0
- package/dist/schemas/action.schema.d.ts +2 -1
- package/dist/schemas/action.schema.js +2 -2
- package/dist/schemas/content.schema.d.ts +134 -1
- package/dist/schemas/content.schema.js +240 -4
- package/dist/utils/merge-metrics-breakdown.util.d.ts +12 -0
- package/dist/utils/merge-metrics-breakdown.util.js +36 -0
- package/dist/utils/normalize-content-link.util.d.ts +12 -0
- package/dist/utils/normalize-content-link.util.js +33 -0
- package/docs/code-review.md +27 -0
- package/docs/constitution.md +75 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/schemas/action.schema.ts +4 -3
- package/src/schemas/content.schema.ts +218 -4
- package/src/utils/merge-metrics-breakdown.util.ts +51 -0
- package/src/utils/normalize-content-link.util.ts +31 -0
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
|
-
|
|
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
|
|
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
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
|
-
|
|
153
|
-
@
|
|
154
|
-
type: 'seeding'
|
|
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
|
-
|
|
333
|
-
|
|
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
|
+
};
|