@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 CHANGED
@@ -36,3 +36,4 @@ 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';
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
- type: 'seeding' | 'ugc' | 'creator';
94
+ /** organic = Orgânicos do mês: post aprovado numa Ação de produto sai dela. Ver GD-699. */
95
+ type: 'seeding' | 'ugc' | 'creator' | 'organic';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grapadigital/shared-schemas",
3
- "version": "1.0.219",
3
+ "version": "1.0.221",
4
4
  "description": "Shared Mongoose Schemas",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -36,3 +36,4 @@ 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';
@@ -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
- @ApiProperty({ enum: ['seeding', 'ugc', 'creator'], example: 'seeding' })
111
- @Prop({ type: String, enum: ['seeding', 'ugc', 'creator'], required: true })
112
- type: 'seeding' | 'ugc' | 'creator';
152
+ /** organic = Orgânicos do mês: post aprovado numa Ação de produto sai dela. Ver GD-699. */
153
+ @ApiProperty({ enum: ['seeding', 'ugc', 'creator', 'organic'], example: 'seeding' })
154
+ @Prop({ type: String, enum: ['seeding', 'ugc', 'creator', 'organic'], required: true })
155
+ type: 'seeding' | 'ugc' | 'creator' | 'organic';
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
+ };