@grapadigital/shared-schemas 1.0.219 → 1.0.221
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 +1 -0
- package/dist/index.js +1 -0
- package/dist/schemas/action.schema.d.ts +40 -2
- package/dist/schemas/action.schema.js +65 -3
- package/dist/schemas/client.schema.d.ts +1 -1
- package/dist/schemas/client.schema.js +8 -0
- package/dist/schemas/content.schema.d.ts +21 -0
- package/dist/schemas/content.schema.js +41 -1
- 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/docs/specs/GD-692.md +112 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/schemas/action.schema.ts +65 -4
- package/src/schemas/client.schema.ts +8 -0
- package/src/schemas/content.schema.ts +49 -0
- package/src/utils/normalize-content-link.util.ts +31 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -57,3 +57,4 @@ __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);
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose';
|
|
27
27
|
import { Recruitment } from './recruitment.schema';
|
|
28
28
|
import { Content } from './content.schema';
|
|
29
|
-
import { Client } from './client.schema';
|
|
29
|
+
import { Client, ClientMetricGoal } from './client.schema';
|
|
30
30
|
import { Sale } from './sale.schema';
|
|
31
31
|
import { Campaign } from './campaign.schema';
|
|
32
32
|
import { Note } from './note.schema';
|
|
@@ -54,6 +54,34 @@ export declare const ActionPromptConfigSchema: MongooseSchema<ActionPromptConfig
|
|
|
54
54
|
}>;
|
|
55
55
|
export declare const ACTION_STATUS_VALUES: readonly ["config", "inProgress", "finished", "archived", "cancelled"];
|
|
56
56
|
export type ActionStatus = (typeof ACTION_STATUS_VALUES)[number];
|
|
57
|
+
/**
|
|
58
|
+
* Fase da Ação (antigo briefing do genc). Com `_id` porque o Conteúdo aponta
|
|
59
|
+
* para ela (`Content.phase`). Período e hashtags opcionais fazem a captura
|
|
60
|
+
* sugerir a Fase sozinha; Fase sem os dois nunca é automática. Fases podem se
|
|
61
|
+
* sobrepor: vale a primeira por `order`. Ver GD-691.
|
|
62
|
+
*/
|
|
63
|
+
export declare class ActionPhase {
|
|
64
|
+
_id: Types.ObjectId;
|
|
65
|
+
name: string;
|
|
66
|
+
/** Hex #rrggbb, da paleta do cpanel. */
|
|
67
|
+
color: string;
|
|
68
|
+
order: number;
|
|
69
|
+
/** Inclusivo. */
|
|
70
|
+
startDate?: Date | null;
|
|
71
|
+
/** Inclusivo (vale o dia inteiro). */
|
|
72
|
+
endDate?: Date | null;
|
|
73
|
+
/** Normalizadas (minúsculas, sem # e sem acento). */
|
|
74
|
+
hashtags: string[];
|
|
75
|
+
}
|
|
76
|
+
export declare const ActionPhaseSchema: MongooseSchema<ActionPhase, import("mongoose").Model<ActionPhase, any, any, any, import("mongoose").Document<unknown, any, ActionPhase, any, {}> & ActionPhase & Required<{
|
|
77
|
+
_id: Types.ObjectId;
|
|
78
|
+
}> & {
|
|
79
|
+
__v: number;
|
|
80
|
+
}, any>, {}, {}, {}, {}, import("mongoose").DefaultSchemaOptions, ActionPhase, import("mongoose").Document<unknown, {}, import("mongoose").FlatRecord<ActionPhase>, {}, import("mongoose").ResolveSchemaOptions<import("mongoose").DefaultSchemaOptions>> & import("mongoose").FlatRecord<ActionPhase> & Required<{
|
|
81
|
+
_id: Types.ObjectId;
|
|
82
|
+
}> & {
|
|
83
|
+
__v: number;
|
|
84
|
+
}>;
|
|
57
85
|
export declare class Action {
|
|
58
86
|
_id?: Types.ObjectId;
|
|
59
87
|
title: string;
|
|
@@ -63,7 +91,8 @@ export declare class Action {
|
|
|
63
91
|
nps?: number;
|
|
64
92
|
status: ActionStatus;
|
|
65
93
|
partnership: 'partner' | 'exclusive';
|
|
66
|
-
|
|
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';
|
|
67
96
|
socialnetworks?: string[];
|
|
68
97
|
recruitedInfluencers?: number;
|
|
69
98
|
hiredInfluencers?: number;
|
|
@@ -100,6 +129,15 @@ export declare class Action {
|
|
|
100
129
|
businessUnit?: ActionBusinessUnit;
|
|
101
130
|
campaign?: Types.ObjectId | Campaign;
|
|
102
131
|
promptConfig?: ActionPromptConfig | null;
|
|
132
|
+
/**
|
|
133
|
+
* Hashtags que marcam o Conteúdo como Orgânico (Tipo, fora do escopo
|
|
134
|
+
* contratado). Normalizadas. Vazio = a captura não sugere Tipo. Ver GD-691.
|
|
135
|
+
*/
|
|
136
|
+
organicHashtags?: string[];
|
|
137
|
+
/** Fases da Ação. Ver ActionPhase e GD-691. */
|
|
138
|
+
phases?: ActionPhase[];
|
|
139
|
+
/** Metas da Ação: mesmo { metric, value } e catálogo dos Tiers do cliente. Ver GD-691. */
|
|
140
|
+
goals?: ClientMetricGoal[];
|
|
103
141
|
}
|
|
104
142
|
export declare const ActionSchema: MongooseSchema<Action, import("mongoose").Model<Action, any, any, any, import("mongoose").Document<unknown, any, Action, any, {}> & Action & Required<{
|
|
105
143
|
_id: Types.ObjectId;
|
|
@@ -9,7 +9,7 @@ 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.ActionModel = exports.ActionSchema = exports.Action = exports.ACTION_STATUS_VALUES = exports.ActionPromptConfigSchema = exports.ActionPromptConfig = exports.ACTION_BUSINESS_UNIT_VALUES = void 0;
|
|
12
|
+
exports.ActionModel = exports.ActionSchema = exports.Action = exports.ActionPhaseSchema = exports.ActionPhase = exports.ACTION_STATUS_VALUES = exports.ActionPromptConfigSchema = exports.ActionPromptConfig = exports.ACTION_BUSINESS_UNIT_VALUES = void 0;
|
|
13
13
|
const mongoose_1 = require("@nestjs/mongoose");
|
|
14
14
|
const swagger_1 = require("@nestjs/swagger");
|
|
15
15
|
const mongoose_2 = require("mongoose");
|
|
@@ -75,6 +75,53 @@ exports.ACTION_STATUS_VALUES = [
|
|
|
75
75
|
'archived',
|
|
76
76
|
'cancelled',
|
|
77
77
|
];
|
|
78
|
+
/**
|
|
79
|
+
* Fase da Ação (antigo briefing do genc). Com `_id` porque o Conteúdo aponta
|
|
80
|
+
* para ela (`Content.phase`). Período e hashtags opcionais fazem a captura
|
|
81
|
+
* sugerir a Fase sozinha; Fase sem os dois nunca é automática. Fases podem se
|
|
82
|
+
* sobrepor: vale a primeira por `order`. Ver GD-691.
|
|
83
|
+
*/
|
|
84
|
+
let ActionPhase = class ActionPhase {
|
|
85
|
+
};
|
|
86
|
+
__decorate([
|
|
87
|
+
(0, swagger_1.ApiProperty)({ example: '6710a1b2c3d4e5f601234567' }),
|
|
88
|
+
__metadata("design:type", mongoose_2.Types.ObjectId)
|
|
89
|
+
], ActionPhase.prototype, "_id", void 0);
|
|
90
|
+
__decorate([
|
|
91
|
+
(0, swagger_1.ApiProperty)({ example: 'Lançamento' }),
|
|
92
|
+
(0, mongoose_1.Prop)({ type: String, required: true }),
|
|
93
|
+
__metadata("design:type", String)
|
|
94
|
+
], ActionPhase.prototype, "name", void 0);
|
|
95
|
+
__decorate([
|
|
96
|
+
(0, swagger_1.ApiProperty)({ example: '#2563eb' }),
|
|
97
|
+
(0, mongoose_1.Prop)({ type: String, required: true }),
|
|
98
|
+
__metadata("design:type", String)
|
|
99
|
+
], ActionPhase.prototype, "color", void 0);
|
|
100
|
+
__decorate([
|
|
101
|
+
(0, swagger_1.ApiProperty)({ example: 0, description: 'Prioridade quando Fases se sobrepõem (menor vale primeiro)' }),
|
|
102
|
+
(0, mongoose_1.Prop)({ type: Number, required: true }),
|
|
103
|
+
__metadata("design:type", Number)
|
|
104
|
+
], ActionPhase.prototype, "order", void 0);
|
|
105
|
+
__decorate([
|
|
106
|
+
(0, swagger_1.ApiProperty)({ type: Date, required: false, nullable: true }),
|
|
107
|
+
(0, mongoose_1.Prop)({ type: Date, required: false, default: null }),
|
|
108
|
+
__metadata("design:type", Object)
|
|
109
|
+
], ActionPhase.prototype, "startDate", void 0);
|
|
110
|
+
__decorate([
|
|
111
|
+
(0, swagger_1.ApiProperty)({ type: Date, required: false, nullable: true }),
|
|
112
|
+
(0, mongoose_1.Prop)({ type: Date, required: false, default: null }),
|
|
113
|
+
__metadata("design:type", Object)
|
|
114
|
+
], ActionPhase.prototype, "endDate", void 0);
|
|
115
|
+
__decorate([
|
|
116
|
+
(0, swagger_1.ApiProperty)({ type: [String], example: ['galaxys26'] }),
|
|
117
|
+
(0, mongoose_1.Prop)({ type: [String], default: [] }),
|
|
118
|
+
__metadata("design:type", Array)
|
|
119
|
+
], ActionPhase.prototype, "hashtags", void 0);
|
|
120
|
+
ActionPhase = __decorate([
|
|
121
|
+
(0, mongoose_1.Schema)()
|
|
122
|
+
], ActionPhase);
|
|
123
|
+
exports.ActionPhase = ActionPhase;
|
|
124
|
+
exports.ActionPhaseSchema = mongoose_1.SchemaFactory.createForClass(ActionPhase);
|
|
78
125
|
let Action = class Action {
|
|
79
126
|
};
|
|
80
127
|
__decorate([
|
|
@@ -123,8 +170,8 @@ __decorate([
|
|
|
123
170
|
__metadata("design:type", String)
|
|
124
171
|
], Action.prototype, "partnership", void 0);
|
|
125
172
|
__decorate([
|
|
126
|
-
(0, swagger_1.ApiProperty)({ enum: ['seeding', 'ugc', 'creator'], example: 'seeding' }),
|
|
127
|
-
(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 }),
|
|
128
175
|
__metadata("design:type", String)
|
|
129
176
|
], Action.prototype, "type", void 0);
|
|
130
177
|
__decorate([
|
|
@@ -297,6 +344,21 @@ __decorate([
|
|
|
297
344
|
(0, mongoose_1.Prop)({ type: exports.ActionPromptConfigSchema, required: false, default: null }),
|
|
298
345
|
__metadata("design:type", Object)
|
|
299
346
|
], Action.prototype, "promptConfig", void 0);
|
|
347
|
+
__decorate([
|
|
348
|
+
(0, swagger_1.ApiProperty)({ type: [String], required: false, example: ['galaxymoments'] }),
|
|
349
|
+
(0, mongoose_1.Prop)({ type: [String], default: [] }),
|
|
350
|
+
__metadata("design:type", Array)
|
|
351
|
+
], Action.prototype, "organicHashtags", void 0);
|
|
352
|
+
__decorate([
|
|
353
|
+
(0, swagger_1.ApiProperty)({ type: () => [ActionPhase], required: false }),
|
|
354
|
+
(0, mongoose_1.Prop)({ type: [exports.ActionPhaseSchema], default: [] }),
|
|
355
|
+
__metadata("design:type", Array)
|
|
356
|
+
], Action.prototype, "phases", void 0);
|
|
357
|
+
__decorate([
|
|
358
|
+
(0, swagger_1.ApiProperty)({ type: () => [client_schema_1.ClientMetricGoal], required: false }),
|
|
359
|
+
(0, mongoose_1.Prop)({ type: [client_schema_1.ClientMetricGoalSchema], default: [] }),
|
|
360
|
+
__metadata("design:type", Array)
|
|
361
|
+
], Action.prototype, "goals", void 0);
|
|
300
362
|
Action = __decorate([
|
|
301
363
|
(0, mongoose_1.Schema)({ timestamps: true })
|
|
302
364
|
], Action);
|
|
@@ -40,7 +40,7 @@ export type ClientCustomization = (typeof CLIENT_CUSTOMIZATION_VALUES)[number];
|
|
|
40
40
|
* `viralReachEngagementRate` é o limite de viral e só vale no Tier.
|
|
41
41
|
* Ver GD-687.
|
|
42
42
|
*/
|
|
43
|
-
export declare const CLIENT_GOAL_METRIC_VALUES: readonly ["potentialReach", "reachCount", "impressionCount", "engagementCount", "engagementRate", "reachEngagementRate", "qualifiedEngagementRate", "effectiveReach", "emv", "score", "cpmi", "cpi", "cpe", "viralReachEngagementRate"];
|
|
43
|
+
export declare const CLIENT_GOAL_METRIC_VALUES: readonly ["potentialReach", "reachCount", "impressionCount", "engagementCount", "engagementRate", "reachEngagementRate", "qualifiedEngagementRate", "effectiveReach", "emv", "score", "cpmi", "cpi", "cpe", "savedCount", "sharesCount", "vcr", "viralReachEngagementRate"];
|
|
44
44
|
export type ClientGoalMetric = (typeof CLIENT_GOAL_METRIC_VALUES)[number];
|
|
45
45
|
export type ClientGoalMetricUnit = 'count' | 'rate' | 'currency';
|
|
46
46
|
/** Unidade de cada métrica. `rate` é guardado como fração (0,10 = 10%). */
|
|
@@ -40,6 +40,9 @@ exports.CLIENT_GOAL_METRIC_VALUES = [
|
|
|
40
40
|
'cpmi',
|
|
41
41
|
'cpi',
|
|
42
42
|
'cpe',
|
|
43
|
+
'savedCount',
|
|
44
|
+
'sharesCount',
|
|
45
|
+
'vcr',
|
|
43
46
|
'viralReachEngagementRate',
|
|
44
47
|
];
|
|
45
48
|
/** Unidade de cada métrica. `rate` é guardado como fração (0,10 = 10%). */
|
|
@@ -57,6 +60,9 @@ exports.CLIENT_GOAL_METRIC_UNITS = {
|
|
|
57
60
|
cpmi: 'currency',
|
|
58
61
|
cpi: 'currency',
|
|
59
62
|
cpe: 'currency',
|
|
63
|
+
savedCount: 'count',
|
|
64
|
+
sharesCount: 'count',
|
|
65
|
+
vcr: 'rate',
|
|
60
66
|
viralReachEngagementRate: 'rate',
|
|
61
67
|
};
|
|
62
68
|
let Cluster = class Cluster {
|
|
@@ -111,10 +117,12 @@ exports.CommissionRangeSchema = mongoose_1.SchemaFactory.createForClass(Commissi
|
|
|
111
117
|
let ClientMetricGoal = class ClientMetricGoal {
|
|
112
118
|
};
|
|
113
119
|
__decorate([
|
|
120
|
+
(0, swagger_1.ApiProperty)({ enum: exports.CLIENT_GOAL_METRIC_VALUES, example: 'potentialReach' }),
|
|
114
121
|
(0, mongoose_1.Prop)({ type: String, enum: exports.CLIENT_GOAL_METRIC_VALUES, required: true }),
|
|
115
122
|
__metadata("design:type", String)
|
|
116
123
|
], ClientMetricGoal.prototype, "metric", void 0);
|
|
117
124
|
__decorate([
|
|
125
|
+
(0, swagger_1.ApiProperty)({ example: 120000000, description: 'Na unidade da métrica; percentuais em fração (0,25 = 25%)' }),
|
|
118
126
|
(0, mongoose_1.Prop)({ type: Number, required: true }),
|
|
119
127
|
__metadata("design:type", Number)
|
|
120
128
|
], ClientMetricGoal.prototype, "value", void 0);
|
|
@@ -157,6 +157,9 @@ export declare const CONTENT_REPORTABLE_STATUS_VALUES: ContentStatus[];
|
|
|
157
157
|
export declare const CONTENT_COMMENT_COUNT_SOURCE_VALUES: readonly ["primetag", "exportcomments"];
|
|
158
158
|
export type ContentCommentCountSource = (typeof CONTENT_COMMENT_COUNT_SOURCE_VALUES)[number];
|
|
159
159
|
export declare const CONTENT_METRICS_SOURCE_VALUES: readonly ["real", "organic", "estimated"];
|
|
160
|
+
/** Origem do Tipo e da Fase: auto = regra da captura, manual = quem revisou. Ver GD-691. */
|
|
161
|
+
export declare const CONTENT_CLASSIFICATION_SOURCE_VALUES: readonly ["auto", "manual"];
|
|
162
|
+
export type ContentClassificationSource = (typeof CONTENT_CLASSIFICATION_SOURCE_VALUES)[number];
|
|
160
163
|
export type ContentMetricsSource = (typeof CONTENT_METRICS_SOURCE_VALUES)[number];
|
|
161
164
|
export declare class ContentStatusChangedBy {
|
|
162
165
|
team?: string;
|
|
@@ -207,6 +210,24 @@ export declare class Content {
|
|
|
207
210
|
editedAt?: Date;
|
|
208
211
|
followersAtPost?: number | null;
|
|
209
212
|
history?: ContentMetricsHistoryEntry[];
|
|
213
|
+
/** Hashtags da publicação, normalizadas (minúsculas, sem # e sem acento). Ver GD-691. */
|
|
214
|
+
hashtags?: string[];
|
|
215
|
+
/**
|
|
216
|
+
* Tipo do Conteúdo: Orgânico (fora do escopo contratado) ou Pago. null =
|
|
217
|
+
* Ação sem hashtag de Orgânico. Não confundir com metricsSource 'organic'
|
|
218
|
+
* (métrica pública) nem com isBoosted (mídia paga). Ver GD-691.
|
|
219
|
+
*/
|
|
220
|
+
isOrganic?: boolean | null;
|
|
221
|
+
isOrganicSource?: ContentClassificationSource | null;
|
|
222
|
+
/** _id de uma Action.phases. Ver GD-691. */
|
|
223
|
+
phase?: Types.ObjectId | null;
|
|
224
|
+
phaseSource?: ContentClassificationSource | null;
|
|
225
|
+
/**
|
|
226
|
+
* Link normalizado (normalizeContentLink) para achar o mesmo post em outras
|
|
227
|
+
* Ações. Preenchido pelo hook de save; quem troca o link por updateOne grava
|
|
228
|
+
* junto. Ver GD-699.
|
|
229
|
+
*/
|
|
230
|
+
linkKey?: string | null;
|
|
210
231
|
}
|
|
211
232
|
export declare const ContentSchema: MongooseSchema<Content, import("mongoose").Model<Content, any, any, any, import("mongoose").Document<unknown, any, Content, any, {}> & Content & Required<{
|
|
212
233
|
_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_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.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([
|
|
@@ -273,6 +274,8 @@ exports.CONTENT_REPORTABLE_STATUS_VALUES = [
|
|
|
273
274
|
];
|
|
274
275
|
exports.CONTENT_COMMENT_COUNT_SOURCE_VALUES = ['primetag', 'exportcomments'];
|
|
275
276
|
exports.CONTENT_METRICS_SOURCE_VALUES = ['real', 'organic', 'estimated'];
|
|
277
|
+
/** Origem do Tipo e da Fase: auto = regra da captura, manual = quem revisou. Ver GD-691. */
|
|
278
|
+
exports.CONTENT_CLASSIFICATION_SOURCE_VALUES = ['auto', 'manual'];
|
|
276
279
|
let ContentStatusChangedBy = class ContentStatusChangedBy {
|
|
277
280
|
};
|
|
278
281
|
__decorate([
|
|
@@ -449,17 +452,54 @@ __decorate([
|
|
|
449
452
|
(0, mongoose_1.Prop)({ type: [exports.ContentMetricsHistoryEntrySchema], required: false, default: [] }),
|
|
450
453
|
__metadata("design:type", Array)
|
|
451
454
|
], Content.prototype, "history", void 0);
|
|
455
|
+
__decorate([
|
|
456
|
+
(0, mongoose_1.Prop)({ type: [String], default: [] }),
|
|
457
|
+
__metadata("design:type", Array)
|
|
458
|
+
], Content.prototype, "hashtags", void 0);
|
|
459
|
+
__decorate([
|
|
460
|
+
(0, mongoose_1.Prop)({ type: Boolean, required: false, default: null }),
|
|
461
|
+
__metadata("design:type", Object)
|
|
462
|
+
], Content.prototype, "isOrganic", void 0);
|
|
463
|
+
__decorate([
|
|
464
|
+
(0, mongoose_1.Prop)({ type: String, enum: exports.CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null }),
|
|
465
|
+
__metadata("design:type", Object)
|
|
466
|
+
], Content.prototype, "isOrganicSource", void 0);
|
|
467
|
+
__decorate([
|
|
468
|
+
(0, mongoose_1.Prop)({ type: mongoose_2.Schema.Types.ObjectId, required: false, default: null }),
|
|
469
|
+
__metadata("design:type", Object)
|
|
470
|
+
], Content.prototype, "phase", void 0);
|
|
471
|
+
__decorate([
|
|
472
|
+
(0, mongoose_1.Prop)({ type: String, enum: exports.CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null }),
|
|
473
|
+
__metadata("design:type", Object)
|
|
474
|
+
], Content.prototype, "phaseSource", void 0);
|
|
475
|
+
__decorate([
|
|
476
|
+
(0, mongoose_1.Prop)({ type: String, required: false, default: null }),
|
|
477
|
+
__metadata("design:type", Object)
|
|
478
|
+
], Content.prototype, "linkKey", void 0);
|
|
452
479
|
Content = __decorate([
|
|
453
480
|
(0, mongoose_1.Schema)({ timestamps: true })
|
|
454
481
|
], Content);
|
|
455
482
|
exports.Content = Content;
|
|
456
483
|
exports.ContentSchema = mongoose_1.SchemaFactory.createForClass(Content);
|
|
484
|
+
// Mantém o linkKey em todo create/save (captura, create-content, scripts com o
|
|
485
|
+
// schema). updateOne/findOneAndUpdate não passam por aqui.
|
|
486
|
+
exports.ContentSchema.pre('save', function (next) {
|
|
487
|
+
if (this.isNew || this.isModified('link')) {
|
|
488
|
+
this.linkKey = (0, normalize_content_link_util_1.normalizeContentLink)(this.link);
|
|
489
|
+
}
|
|
490
|
+
next();
|
|
491
|
+
});
|
|
457
492
|
exports.ContentSchema.index({ action: 1, status: 1 });
|
|
458
493
|
exports.ContentSchema.index({ action: 1, profile: 1, type: 1 });
|
|
459
494
|
exports.ContentSchema.index({ action: 1, profile: 1, socialnetwork: 1 });
|
|
460
495
|
exports.ContentSchema.index({ action: 1, status: 1, postedAt: 1 });
|
|
461
496
|
exports.ContentSchema.index({ profile: 1 });
|
|
497
|
+
exports.ContentSchema.index({ action: 1, phase: 1 });
|
|
498
|
+
exports.ContentSchema.index({ action: 1, isOrganic: 1 });
|
|
462
499
|
exports.ContentSchema.index({ createdAt: -1, status: 1 });
|
|
500
|
+
// Trava de aprovação entre Ações (GD-699): busca o mesmo post por publicação ou link.
|
|
501
|
+
exports.ContentSchema.index({ primetagId: 1 });
|
|
502
|
+
exports.ContentSchema.index({ linkKey: 1 });
|
|
463
503
|
// Chave de idempotência da captura da Primetag — conteúdos manuais (sem
|
|
464
504
|
// primetagId) ficam fora do índice.
|
|
465
505
|
exports.ContentSchema.index({ action: 1, primetagId: 1 }, { unique: true, partialFilterExpression: { primetagId: { $type: 'string' } } });
|
|
@@ -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`
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Spec: action + content: Orgânico, Fases e Metas da Ação
|
|
2
|
+
|
|
3
|
+
**Feature:** GD-692 (Story GD-691)
|
|
4
|
+
**Consumido por:** api-campaigns (GD-693), content-capture-trigger e content-capture-worker (GD-693), web-campaigns (GD-694, via shared-app-modules)
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Descrição
|
|
9
|
+
|
|
10
|
+
A Ação da Samsung passa a guardar a hashtag de Orgânico, as Fases (antigo briefing do genc) e as
|
|
11
|
+
Metas da Ação. O Conteúdo passa a guardar as próprias hashtags e o Tipo (Orgânico ou Pago) e a Fase
|
|
12
|
+
sugeridos, cada um com a origem (`auto` = regra da captura, `manual` = quem revisou).
|
|
13
|
+
|
|
14
|
+
Todos os campos são opcionais e têm default vazio: Ações e Conteúdos existentes não mudam.
|
|
15
|
+
|
|
16
|
+
**Nome:** "Orgânico" aqui é o Tipo do Conteúdo (fora do escopo contratado). Não confundir com
|
|
17
|
+
`metricsSource: 'organic'` (Métrica pública) nem com `isBoosted` (mídia paga, GD-707).
|
|
18
|
+
|
|
19
|
+
## Schema
|
|
20
|
+
|
|
21
|
+
### `action.schema.ts`
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
@Schema() // com _id: o Conteúdo aponta para a Fase
|
|
25
|
+
export class ActionPhase {
|
|
26
|
+
_id: Types.ObjectId;
|
|
27
|
+
name: string;
|
|
28
|
+
color: string; // hex #rrggbb (paleta do cpanel)
|
|
29
|
+
order: number; // 0..n, define a prioridade quando Fases se sobrepõem
|
|
30
|
+
startDate?: Date | null; // inclusivo
|
|
31
|
+
endDate?: Date | null; // inclusivo (dia inteiro)
|
|
32
|
+
hashtags: string[]; // normalizadas, default []
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Action.organicHashtags: string[] // normalizadas, default []
|
|
36
|
+
Action.phases: ActionPhase[] // default []
|
|
37
|
+
Action.goals: ClientMetricGoal[] // default []; mesmo { metric, value } e catálogo dos Tiers (GD-688)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### `content.schema.ts`
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
export const CONTENT_CLASSIFICATION_SOURCE_VALUES = ['auto', 'manual'] as const;
|
|
44
|
+
|
|
45
|
+
Content.hashtags: string[] // normalizadas, default []
|
|
46
|
+
Content.isOrganic: boolean | null // default null (Ação sem hashtag de Orgânico)
|
|
47
|
+
Content.isOrganicSource: 'auto' | 'manual' | null // default null
|
|
48
|
+
Content.phase: Types.ObjectId | null // _id de Action.phases; default null
|
|
49
|
+
Content.phaseSource: 'auto' | 'manual' | null // default null
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Índices novos: `ContentSchema.index({ action: 1, phase: 1 })` e `ContentSchema.index({ action: 1, isOrganic: 1 })`.
|
|
53
|
+
|
|
54
|
+
### Catálogo de métricas (`client.schema.ts`)
|
|
55
|
+
|
|
56
|
+
`CLIENT_GOAL_METRIC_VALUES` ganha as métricas do relatório Samsung que faltavam, com a unidade em
|
|
57
|
+
`CLIENT_GOAL_METRIC_UNITS`:
|
|
58
|
+
|
|
59
|
+
| métrica | unidade |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `savedCount` | count |
|
|
62
|
+
| `sharesCount` | count |
|
|
63
|
+
| `vcr` | rate (a métrica entra no Content na GD-707) |
|
|
64
|
+
|
|
65
|
+
O mesmo vale no espelho do shared-app-modules (`CLIENT_GOAL_METRIC_VALUES` com label): "Salvamentos",
|
|
66
|
+
"Compartilhamentos", "VCR".
|
|
67
|
+
|
|
68
|
+
## Normalização de hashtag
|
|
69
|
+
|
|
70
|
+
Regra única, implementada **localmente em cada repo que usa** (decisão da Story): api-campaigns,
|
|
71
|
+
content-capture-worker e web-campaigns. Não vai para o shared-schemas.
|
|
72
|
+
|
|
73
|
+
`normalizeHashtag(value)`: tira espaços e `#` do começo, remove acentos (NFD), passa para
|
|
74
|
+
minúsculas e remove o que não for `[a-z0-9_]`.
|
|
75
|
+
|
|
76
|
+
- `"#GalaxyMoments"` → `"galaxymoments"`
|
|
77
|
+
- `"#VerãoSamsung"` → `"veraosamsung"`
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Arquivos
|
|
82
|
+
|
|
83
|
+
- Alterar `src/schemas/action.schema.ts` (ActionPhase + 3 campos).
|
|
84
|
+
- Alterar `src/schemas/content.schema.ts` (5 campos, enum de origem, 2 índices).
|
|
85
|
+
- Alterar `src/schemas/client.schema.ts` (3 métricas no catálogo).
|
|
86
|
+
- Alterar `package.json` (1.0.220).
|
|
87
|
+
- shared-app-modules: `src/app/constants/values.constant.ts` (3 métricas com label) e
|
|
88
|
+
`src/app/interfaces/action/action.interface.ts` (`organicHashtags`, `phases`, `goals`, mais
|
|
89
|
+
`ActionPhaseInterface`); interface de Content usada no web-campaigns, se houver no pacote.
|
|
90
|
+
|
|
91
|
+
## Passos
|
|
92
|
+
|
|
93
|
+
1. `ActionPhase` com `_id` (subdocumento com id) e os três campos novos na Action, com JSDoc em PT e "Ver GD-691".
|
|
94
|
+
2. Campos e índices do Content.
|
|
95
|
+
3. Métricas novas no catálogo do client.
|
|
96
|
+
4. `npm run build` e conferir no `dist` os campos e exports; checar com `validateSync` que uma Action e um Content antigos (sem os campos) continuam válidos e que `phases[0]._id` é gerado.
|
|
97
|
+
5. shared-app-modules: catálogo e interfaces; `pnpm run build`.
|
|
98
|
+
6. Publicar os dois pacotes (José).
|
|
99
|
+
|
|
100
|
+
## Dependências
|
|
101
|
+
|
|
102
|
+
- GD-688 (catálogo de métricas) já em produção.
|
|
103
|
+
- Esta Task vem antes da GD-693 e da GD-694.
|
|
104
|
+
|
|
105
|
+
## Validação
|
|
106
|
+
|
|
107
|
+
- `npm run build` no shared-schemas; `pnpm run build` no shared-app-modules.
|
|
108
|
+
- `validateSync` de documentos antigos e novos (script rápido com o `dist`).
|
|
109
|
+
|
|
110
|
+
## Pontos abertos
|
|
111
|
+
|
|
112
|
+
- `vcr` entra no catálogo antes de existir no Content (chega na GD-707). Até lá a meta pode ser cadastrada, mas o Hub mostra o realizado como indisponível.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import { Recruitment } from './recruitment.schema';
|
|
12
12
|
import { Content } from './content.schema';
|
|
13
|
-
import { Client } from './client.schema';
|
|
13
|
+
import { Client, ClientMetricGoal, ClientMetricGoalSchema } from './client.schema';
|
|
14
14
|
import { Sale } from './sale.schema';
|
|
15
15
|
import { Campaign } from './campaign.schema';
|
|
16
16
|
import { Note, NoteSchema } from './note.schema';
|
|
@@ -68,6 +68,48 @@ export const ACTION_STATUS_VALUES = [
|
|
|
68
68
|
] as const;
|
|
69
69
|
export type ActionStatus = (typeof ACTION_STATUS_VALUES)[number];
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Fase da Ação (antigo briefing do genc). Com `_id` porque o Conteúdo aponta
|
|
73
|
+
* para ela (`Content.phase`). Período e hashtags opcionais fazem a captura
|
|
74
|
+
* sugerir a Fase sozinha; Fase sem os dois nunca é automática. Fases podem se
|
|
75
|
+
* sobrepor: vale a primeira por `order`. Ver GD-691.
|
|
76
|
+
*/
|
|
77
|
+
@Schema()
|
|
78
|
+
export class ActionPhase {
|
|
79
|
+
@ApiProperty({ example: '6710a1b2c3d4e5f601234567' })
|
|
80
|
+
_id: Types.ObjectId;
|
|
81
|
+
|
|
82
|
+
@ApiProperty({ example: 'Lançamento' })
|
|
83
|
+
@Prop({ type: String, required: true })
|
|
84
|
+
name: string;
|
|
85
|
+
|
|
86
|
+
/** Hex #rrggbb, da paleta do cpanel. */
|
|
87
|
+
@ApiProperty({ example: '#2563eb' })
|
|
88
|
+
@Prop({ type: String, required: true })
|
|
89
|
+
color: string;
|
|
90
|
+
|
|
91
|
+
@ApiProperty({ example: 0, description: 'Prioridade quando Fases se sobrepõem (menor vale primeiro)' })
|
|
92
|
+
@Prop({ type: Number, required: true })
|
|
93
|
+
order: number;
|
|
94
|
+
|
|
95
|
+
/** Inclusivo. */
|
|
96
|
+
@ApiProperty({ type: Date, required: false, nullable: true })
|
|
97
|
+
@Prop({ type: Date, required: false, default: null })
|
|
98
|
+
startDate?: Date | null;
|
|
99
|
+
|
|
100
|
+
/** Inclusivo (vale o dia inteiro). */
|
|
101
|
+
@ApiProperty({ type: Date, required: false, nullable: true })
|
|
102
|
+
@Prop({ type: Date, required: false, default: null })
|
|
103
|
+
endDate?: Date | null;
|
|
104
|
+
|
|
105
|
+
/** Normalizadas (minúsculas, sem # e sem acento). */
|
|
106
|
+
@ApiProperty({ type: [String], example: ['galaxys26'] })
|
|
107
|
+
@Prop({ type: [String], default: [] })
|
|
108
|
+
hashtags: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const ActionPhaseSchema = SchemaFactory.createForClass(ActionPhase);
|
|
112
|
+
|
|
71
113
|
@Schema({ timestamps: true })
|
|
72
114
|
export class Action {
|
|
73
115
|
@ApiProperty({ example: '64bfc3a78d3f1e2a12345678' })
|
|
@@ -107,9 +149,10 @@ export class Action {
|
|
|
107
149
|
@Prop({ type: String, enum: ['partner', 'exclusive'], required: true })
|
|
108
150
|
partnership: 'partner' | 'exclusive';
|
|
109
151
|
|
|
110
|
-
|
|
111
|
-
@
|
|
112
|
-
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';
|
|
113
156
|
|
|
114
157
|
@ApiProperty({ example: ['instagram', 'tiktok'], required: false })
|
|
115
158
|
@Prop({ type: [String], required: false })
|
|
@@ -252,6 +295,24 @@ export class Action {
|
|
|
252
295
|
@ApiProperty({ type: () => ActionPromptConfig, required: false, description: 'Customização de prompts de classificação de comentário por IA para esta action — campos ausentes caem no prompt genérico default' })
|
|
253
296
|
@Prop({ type: ActionPromptConfigSchema, required: false, default: null })
|
|
254
297
|
promptConfig?: ActionPromptConfig | null;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Hashtags que marcam o Conteúdo como Orgânico (Tipo, fora do escopo
|
|
301
|
+
* contratado). Normalizadas. Vazio = a captura não sugere Tipo. Ver GD-691.
|
|
302
|
+
*/
|
|
303
|
+
@ApiProperty({ type: [String], required: false, example: ['galaxymoments'] })
|
|
304
|
+
@Prop({ type: [String], default: [] })
|
|
305
|
+
organicHashtags?: string[];
|
|
306
|
+
|
|
307
|
+
/** Fases da Ação. Ver ActionPhase e GD-691. */
|
|
308
|
+
@ApiProperty({ type: () => [ActionPhase], required: false })
|
|
309
|
+
@Prop({ type: [ActionPhaseSchema], default: [] })
|
|
310
|
+
phases?: ActionPhase[];
|
|
311
|
+
|
|
312
|
+
/** Metas da Ação: mesmo { metric, value } e catálogo dos Tiers do cliente. Ver GD-691. */
|
|
313
|
+
@ApiProperty({ type: () => [ClientMetricGoal], required: false })
|
|
314
|
+
@Prop({ type: [ClientMetricGoalSchema], default: [] })
|
|
315
|
+
goals?: ClientMetricGoal[];
|
|
255
316
|
}
|
|
256
317
|
|
|
257
318
|
export const ActionSchema = SchemaFactory.createForClass(Action);
|
|
@@ -39,6 +39,9 @@ export const CLIENT_GOAL_METRIC_VALUES = [
|
|
|
39
39
|
'cpmi',
|
|
40
40
|
'cpi',
|
|
41
41
|
'cpe',
|
|
42
|
+
'savedCount',
|
|
43
|
+
'sharesCount',
|
|
44
|
+
'vcr',
|
|
42
45
|
'viralReachEngagementRate',
|
|
43
46
|
] as const;
|
|
44
47
|
export type ClientGoalMetric = (typeof CLIENT_GOAL_METRIC_VALUES)[number];
|
|
@@ -60,6 +63,9 @@ export const CLIENT_GOAL_METRIC_UNITS: Record<ClientGoalMetric, ClientGoalMetric
|
|
|
60
63
|
cpmi: 'currency',
|
|
61
64
|
cpi: 'currency',
|
|
62
65
|
cpe: 'currency',
|
|
66
|
+
savedCount: 'count',
|
|
67
|
+
sharesCount: 'count',
|
|
68
|
+
vcr: 'rate',
|
|
63
69
|
viralReachEngagementRate: 'rate',
|
|
64
70
|
};
|
|
65
71
|
|
|
@@ -101,10 +107,12 @@ export const CommissionRangeSchema = SchemaFactory.createForClass(CommissionRang
|
|
|
101
107
|
|
|
102
108
|
@Schema({ _id: false })
|
|
103
109
|
export class ClientMetricGoal {
|
|
110
|
+
@ApiProperty({ enum: CLIENT_GOAL_METRIC_VALUES, example: 'potentialReach' })
|
|
104
111
|
@Prop({ type: String, enum: CLIENT_GOAL_METRIC_VALUES, required: true })
|
|
105
112
|
metric: ClientGoalMetric;
|
|
106
113
|
|
|
107
114
|
/** Na unidade da métrica (`CLIENT_GOAL_METRIC_UNITS`); `rate` em fração. */
|
|
115
|
+
@ApiProperty({ example: 120000000, description: 'Na unidade da métrica; percentuais em fração (0,25 = 25%)' })
|
|
108
116
|
@Prop({ type: Number, required: true })
|
|
109
117
|
value: number;
|
|
110
118
|
}
|
|
@@ -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
|
|
|
@@ -217,6 +218,10 @@ export const CONTENT_COMMENT_COUNT_SOURCE_VALUES = ['primetag', 'exportcomments'
|
|
|
217
218
|
export type ContentCommentCountSource = (typeof CONTENT_COMMENT_COUNT_SOURCE_VALUES)[number];
|
|
218
219
|
|
|
219
220
|
export const CONTENT_METRICS_SOURCE_VALUES = ['real', 'organic', 'estimated'] as const;
|
|
221
|
+
|
|
222
|
+
/** Origem do Tipo e da Fase: auto = regra da captura, manual = quem revisou. Ver GD-691. */
|
|
223
|
+
export const CONTENT_CLASSIFICATION_SOURCE_VALUES = ['auto', 'manual'] as const;
|
|
224
|
+
export type ContentClassificationSource = (typeof CONTENT_CLASSIFICATION_SOURCE_VALUES)[number];
|
|
220
225
|
export type ContentMetricsSource = (typeof CONTENT_METRICS_SOURCE_VALUES)[number];
|
|
221
226
|
|
|
222
227
|
@Schema({ _id: false })
|
|
@@ -378,16 +383,60 @@ export class Content {
|
|
|
378
383
|
// empurra o `metrics` anterior (antes de ser sobrescrito) pra cá.
|
|
379
384
|
@Prop({ type: [ContentMetricsHistoryEntrySchema], required: false, default: [] })
|
|
380
385
|
history?: ContentMetricsHistoryEntry[];
|
|
386
|
+
|
|
387
|
+
/** Hashtags da publicação, normalizadas (minúsculas, sem # e sem acento). Ver GD-691. */
|
|
388
|
+
@Prop({ type: [String], default: [] })
|
|
389
|
+
hashtags?: string[];
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Tipo do Conteúdo: Orgânico (fora do escopo contratado) ou Pago. null =
|
|
393
|
+
* Ação sem hashtag de Orgânico. Não confundir com metricsSource 'organic'
|
|
394
|
+
* (métrica pública) nem com isBoosted (mídia paga). Ver GD-691.
|
|
395
|
+
*/
|
|
396
|
+
@Prop({ type: Boolean, required: false, default: null })
|
|
397
|
+
isOrganic?: boolean | null;
|
|
398
|
+
|
|
399
|
+
@Prop({ type: String, enum: CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null })
|
|
400
|
+
isOrganicSource?: ContentClassificationSource | null;
|
|
401
|
+
|
|
402
|
+
/** _id de uma Action.phases. Ver GD-691. */
|
|
403
|
+
@Prop({ type: MongooseSchema.Types.ObjectId, required: false, default: null })
|
|
404
|
+
phase?: Types.ObjectId | null;
|
|
405
|
+
|
|
406
|
+
@Prop({ type: String, enum: CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null })
|
|
407
|
+
phaseSource?: ContentClassificationSource | null;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Link normalizado (normalizeContentLink) para achar o mesmo post em outras
|
|
411
|
+
* Ações. Preenchido pelo hook de save; quem troca o link por updateOne grava
|
|
412
|
+
* junto. Ver GD-699.
|
|
413
|
+
*/
|
|
414
|
+
@Prop({ type: String, required: false, default: null })
|
|
415
|
+
linkKey?: string | null;
|
|
381
416
|
}
|
|
382
417
|
|
|
383
418
|
export const ContentSchema = SchemaFactory.createForClass(Content);
|
|
384
419
|
|
|
420
|
+
// Mantém o linkKey em todo create/save (captura, create-content, scripts com o
|
|
421
|
+
// schema). updateOne/findOneAndUpdate não passam por aqui.
|
|
422
|
+
ContentSchema.pre('save', function (next) {
|
|
423
|
+
if (this.isNew || this.isModified('link')) {
|
|
424
|
+
this.linkKey = normalizeContentLink(this.link);
|
|
425
|
+
}
|
|
426
|
+
next();
|
|
427
|
+
});
|
|
428
|
+
|
|
385
429
|
ContentSchema.index({ action: 1, status: 1 });
|
|
386
430
|
ContentSchema.index({ action: 1, profile: 1, type: 1 });
|
|
387
431
|
ContentSchema.index({ action: 1, profile: 1, socialnetwork: 1 });
|
|
388
432
|
ContentSchema.index({ action: 1, status: 1, postedAt: 1 });
|
|
389
433
|
ContentSchema.index({ profile: 1 });
|
|
434
|
+
ContentSchema.index({ action: 1, phase: 1 });
|
|
435
|
+
ContentSchema.index({ action: 1, isOrganic: 1 });
|
|
390
436
|
ContentSchema.index({ createdAt: -1, status: 1 });
|
|
437
|
+
// Trava de aprovação entre Ações (GD-699): busca o mesmo post por publicação ou link.
|
|
438
|
+
ContentSchema.index({ primetagId: 1 });
|
|
439
|
+
ContentSchema.index({ linkKey: 1 });
|
|
391
440
|
// Chave de idempotência da captura da Primetag — conteúdos manuais (sem
|
|
392
441
|
// primetagId) ficam fora do índice.
|
|
393
442
|
ContentSchema.index(
|
|
@@ -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
|
+
};
|