@grapadigital/shared-schemas 1.0.220 → 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);
@@ -91,7 +91,8 @@ export declare class Action {
91
91
  nps?: number;
92
92
  status: ActionStatus;
93
93
  partnership: 'partner' | 'exclusive';
94
- type: 'seeding' | 'ugc' | 'creator';
94
+ /** organic = Orgânicos do mês: post aprovado numa Ação de produto sai dela. Ver GD-699. */
95
+ type: 'seeding' | 'ugc' | 'creator' | 'organic';
95
96
  socialnetworks?: string[];
96
97
  recruitedInfluencers?: number;
97
98
  hiredInfluencers?: number;
@@ -170,8 +170,8 @@ __decorate([
170
170
  __metadata("design:type", String)
171
171
  ], Action.prototype, "partnership", void 0);
172
172
  __decorate([
173
- (0, swagger_1.ApiProperty)({ enum: ['seeding', 'ugc', 'creator'], example: 'seeding' }),
174
- (0, mongoose_1.Prop)({ type: String, enum: ['seeding', 'ugc', 'creator'], required: true }),
173
+ (0, swagger_1.ApiProperty)({ enum: ['seeding', 'ugc', 'creator', 'organic'], example: 'seeding' }),
174
+ (0, mongoose_1.Prop)({ type: String, enum: ['seeding', 'ugc', 'creator', 'organic'], required: true }),
175
175
  __metadata("design:type", String)
176
176
  ], Action.prototype, "type", void 0);
177
177
  __decorate([
@@ -222,6 +222,12 @@ export declare class Content {
222
222
  /** _id de uma Action.phases. Ver GD-691. */
223
223
  phase?: Types.ObjectId | null;
224
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;
225
231
  }
226
232
  export declare const ContentSchema: MongooseSchema<Content, import("mongoose").Model<Content, any, any, any, import("mongoose").Document<unknown, any, Content, any, {}> & Content & Required<{
227
233
  _id: Types.ObjectId;
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
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([
@@ -471,11 +472,23 @@ __decorate([
471
472
  (0, mongoose_1.Prop)({ type: String, enum: exports.CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null }),
472
473
  __metadata("design:type", Object)
473
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);
474
479
  Content = __decorate([
475
480
  (0, mongoose_1.Schema)({ timestamps: true })
476
481
  ], Content);
477
482
  exports.Content = Content;
478
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
+ });
479
492
  exports.ContentSchema.index({ action: 1, status: 1 });
480
493
  exports.ContentSchema.index({ action: 1, profile: 1, type: 1 });
481
494
  exports.ContentSchema.index({ action: 1, profile: 1, socialnetwork: 1 });
@@ -484,6 +497,9 @@ exports.ContentSchema.index({ profile: 1 });
484
497
  exports.ContentSchema.index({ action: 1, phase: 1 });
485
498
  exports.ContentSchema.index({ action: 1, isOrganic: 1 });
486
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 });
487
503
  // Chave de idempotência da captura da Primetag — conteúdos manuais (sem
488
504
  // primetagId) ficam fora do índice.
489
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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grapadigital/shared-schemas",
3
- "version": "1.0.220",
3
+ "version": "1.0.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';
@@ -149,9 +149,10 @@ export class Action {
149
149
  @Prop({ type: String, enum: ['partner', 'exclusive'], required: true })
150
150
  partnership: 'partner' | 'exclusive';
151
151
 
152
- @ApiProperty({ enum: ['seeding', 'ugc', 'creator'], example: 'seeding' })
153
- @Prop({ type: String, enum: ['seeding', 'ugc', 'creator'], required: true })
154
- type: 'seeding' | 'ugc' | 'creator';
152
+ /** organic = Orgânicos do mês: post aprovado numa Ação de produto sai dela. Ver GD-699. */
153
+ @ApiProperty({ enum: ['seeding', 'ugc', 'creator', 'organic'], example: 'seeding' })
154
+ @Prop({ type: String, enum: ['seeding', 'ugc', 'creator', 'organic'], required: true })
155
+ type: 'seeding' | 'ugc' | 'creator' | 'organic';
155
156
 
156
157
  @ApiProperty({ example: ['instagram', 'tiktok'], required: false })
157
158
  @Prop({ type: [String], required: false })
@@ -7,6 +7,7 @@ import {
7
7
  } from 'mongoose';
8
8
  import { Profile } from './profile.schema';
9
9
  import { Action } from './action.schema';
10
+ import { normalizeContentLink } from '../utils/normalize-content-link.util';
10
11
 
11
12
  export type ContentDocument = HydratedDocument<Content>;
12
13
 
@@ -404,10 +405,27 @@ export class Content {
404
405
 
405
406
  @Prop({ type: String, enum: CONTENT_CLASSIFICATION_SOURCE_VALUES, required: false, default: null })
406
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;
407
416
  }
408
417
 
409
418
  export const ContentSchema = SchemaFactory.createForClass(Content);
410
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
+
411
429
  ContentSchema.index({ action: 1, status: 1 });
412
430
  ContentSchema.index({ action: 1, profile: 1, type: 1 });
413
431
  ContentSchema.index({ action: 1, profile: 1, socialnetwork: 1 });
@@ -416,6 +434,9 @@ ContentSchema.index({ profile: 1 });
416
434
  ContentSchema.index({ action: 1, phase: 1 });
417
435
  ContentSchema.index({ action: 1, isOrganic: 1 });
418
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 });
419
440
  // Chave de idempotência da captura da Primetag — conteúdos manuais (sem
420
441
  // primetagId) ficam fora do índice.
421
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
+ };