Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ erDiagram
int reviewerId FK "Nullable -> User"
datetime reviewedAt "Nullable"
uuid trackItemId FK "Nullable - marco de trilha vinculado"
boolean isTestOut "Default: false - pulou o marco autodeclarando dominio"
enum contributionKind "COMMUNITY_ACTIVITY | TRACK_PROGRESS"
datetime createdAt
datetime updatedAt
}
Expand Down Expand Up @@ -400,13 +402,20 @@ Atividades pré-mapeadas disponíveis para submissão.
---

### `Submission` (Solicitação de Pontos do Usuário)
Quando o usuário executa uma `Activity` e solicita reconhecimento.
Quando o usuário executa uma `Activity` e solicita reconhecimento. A mesma
tabela também registra provas/test-out de marcos de trilha (`trackItemId`
preenchido) — o pipeline de moderação é compartilhado, mas as duas coisas
são semanticamente diferentes (ver `contributionKind` abaixo).

- `description` (String, Nullable) — markdown sanitizado (max 2000 chars); aceita apenas ASCII imprimível + Latin Extended (sem emojis/Unicode especial)
- `awardedXp` (Int) — XP concedido (geralmente herda de `Activity.fixedReward`, mas o moderador pode sobrescrever)
- `reviewerId` (Int, FK -> User) — ID do moderador que revisou
- `trackItemId` (UUID, FK -> TrackItem, Nullable) — quando a submissão é feita no contexto de um marco de trilha
- Ao **aprovar**: gera `Transaction(XP_REWARD)` para o submitter, credita XP no perfil, gera `Transaction(AUDITOR_REWARD)` para o moderador
- `isTestOut` (Boolean) — `true` quando o usuário pulou o marco autodeclarando domínio (nunca gera XP, sempre `status = APPROVED` imediatamente, sem moderação)
- `contributionKind` (Enum `SubmissionContributionKind`) — formaliza a distinção que antes só existia implicitamente pela nulidade de `trackItemId`:
- `COMMUNITY_ACTIVITY` — contribuição real à comunidade (`trackItemId` nulo). Conta para selos de contribuição (ex: "Primeira Missão") e aparece na categoria "Voluntariado" do perfil público.
- `TRACK_PROGRESS` — progresso pessoal de uma trilha de aprendizado (prova aprovada ou test-out). XP é para o próprio usuário, **não** é contribuição para a comunidade — nunca deve contar para selos de contribuição, mesmo quando `status = APPROVED`.
- Ao **aprovar** uma submissão `COMMUNITY_ACTIVITY`: gera `Transaction(XP_REWARD)` para o submitter, credita XP no perfil, gera `Transaction(AUDITOR_REWARD)` para o moderador

---

Expand Down Expand Up @@ -442,6 +451,7 @@ Motor financeiro dos pontos. Toda mutação no perfil gera uma Transaction.
{ "type": "total_xp", "threshold": 500 }
{ "type": "monthly_ranking_top", "threshold": 3 }
```
- `submissions_approved` conta **apenas** `Submission` com `status = APPROVED`, `isTestOut = false` **e** `contributionKind = COMMUNITY_ACTIVITY` (`badge-evaluator.service.ts`). Test-out e provas de trilha aprovadas nunca contam para esse critério — não são contribuição para a comunidade, mesmo que gerem XP pessoal.

---

Expand Down
16 changes: 13 additions & 3 deletions src/badges/badge-evaluator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GamificationProfileBadgeRepository } from './infrastructure/persistence
import { BadgeCriteriaTypeEnum } from './domain/badge-criteria-type.enum';
import { SubmissionEntity } from '../submissions/infrastructure/persistence/relational/entities/submission.entity';
import { SubmissionStatus } from '../submissions/domain/submission-status.enum';
import { SubmissionContributionKind } from '../submissions/domain/submission-contribution-kind.enum';
import { TransactionEntity } from '../transactions/infrastructure/persistence/relational/entities/transaction.entity';
import { TransactionCategoryEnum } from '../transactions/domain/transaction-category.enum';
import { GamificationProfileEntity } from '../gamification-profiles/infrastructure/persistence/relational/entities/gamification-profile.entity';
Expand Down Expand Up @@ -89,13 +90,22 @@ export class BadgeEvaluatorService {
}
}

// Só conta submissões que são contribuição real à comunidade — nunca
// progresso pessoal de trilha (nem prova aprovada nem test-out), mesmo que
// status seja APPROVED. Um test-out chega a criar uma submission APPROVED
// com 0 XP; sem esse filtro, pular uma prova já "conta" como contribuição.
private async checkSubmissionsApproved(
profileId: string,
threshold: number,
): Promise<boolean> {
const count = await this.dataSource
.getRepository(SubmissionEntity)
.count({ where: { profileId, status: SubmissionStatus.APPROVED } });
const count = await this.dataSource.getRepository(SubmissionEntity).count({
where: {
profileId,
status: SubmissionStatus.APPROVED,
isTestOut: false,
contributionKind: SubmissionContributionKind.COMMUNITY_ACTIVITY,
},
});
return count >= threshold;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddSubmissionContributionKind1785424960820
implements MigrationInterface
{
name = 'AddSubmissionContributionKind1785424960820';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "public"."submission_contributionkind_enum" AS ENUM('COMMUNITY_ACTIVITY', 'TRACK_PROGRESS')`,
);
await queryRunner.query(
`ALTER TABLE "submission" ADD "contributionKind" "public"."submission_contributionkind_enum"`,
);

// Backfill: até aqui, a única forma de saber o tipo era a nulidade de
// trackItemId — formalizamos isso como coluna explícita para não
// depender mais dessa inferência implícita em regras de negócio.
await queryRunner.query(
`UPDATE "submission" SET "contributionKind" = (CASE WHEN "trackItemId" IS NULL THEN 'COMMUNITY_ACTIVITY' ELSE 'TRACK_PROGRESS' END)::"public"."submission_contributionkind_enum"`,
);

await queryRunner.query(
`ALTER TABLE "submission" ALTER COLUMN "contributionKind" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "submission" ALTER COLUMN "contributionKind" SET DEFAULT 'COMMUNITY_ACTIVITY'`,
);

// Limpeza: o selo "Primeira Missão" (e por extensão "Colaborador" /
// "Herói da Comunidade") contava qualquer submission com status
// APPROVED, incluindo test-out de marcos de trilha (0 XP, não é
// contribuição). Revoga concessões que não têm nenhuma submissão real
// de comunidade aprovada por trás — usuários voltam a poder ganhá-lo
// legitimamente na próxima avaliação de moderação.
await queryRunner.query(`
DELETE FROM "gamification_profile_badge" gpb
USING "badge" b
WHERE gpb."badgeId" = b.id
AND b.name IN ('Primeira Missão', 'Colaborador', 'Herói da Comunidade')
AND (
SELECT COUNT(*) FROM "submission" s
WHERE s."profileId" = gpb."profileId"
AND s.status = 'APPROVED'
AND s."isTestOut" = false
AND s."contributionKind" = 'COMMUNITY_ACTIVITY'
) < (b."criteriaConfig"->>'threshold')::int
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "submission" DROP COLUMN "contributionKind"`,
);
await queryRunner.query(
`DROP TYPE "public"."submission_contributionkind_enum"`,
);
}
}
40 changes: 40 additions & 0 deletions src/gamification-profiles/gamification-profiles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import { FindAllGamificationProfilesDto } from './dto/find-all-gamification-prof
import { UpdateMyGamificationProfileDto } from './dto/update-my-gamification-profile.dto';
import { SubmissionRepository } from '../submissions/infrastructure/persistence/submission.repository';
import { Submission } from '../submissions/domain/submission';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CourseReviewEntity } from '../course-reviews/infrastructure/persistence/relational/entities/course-review.entity';

@ApiTags('Gamification Profiles')
@Controller({
Expand All @@ -48,6 +51,7 @@ export class GamificationProfilesController {
constructor(
private readonly gamificationProfilesService: GamificationProfilesService,
private readonly submissionRepository: SubmissionRepository,
@InjectDataSource() private readonly dataSource: DataSource,
) {}

@Post()
Expand Down Expand Up @@ -214,6 +218,42 @@ export class GamificationProfilesController {
);
}

// Feed enxuto de avaliações de curso feitas pelo perfil, pra timeline
// pública mostrar como categoria própria e discreta ("avaliou o curso X").
// Consulta direto via DataSource (padrão já usado em profile-portfolio)
// pra não criar uma dependência de módulo circular com CourseReviewsModule
// (que já depende de GamificationProfilesModule).
@Get(':id/course-reviews')
@ApiParam({
name: 'id',
type: String,
required: true,
})
async findCourseReviews(@Param('id') id: string) {
const rows = await this.dataSource
.getRepository(CourseReviewEntity)
.createQueryBuilder('review')
.innerJoin('review.course', 'course')
.select([
'review.id AS id',
'review.courseId AS "courseId"',
'course.title AS "courseTitle"',
'review.rating AS rating',
'review.createdAt AS "createdAt"',
])
.where('review.profileId = :id', { id })
.orderBy('review.createdAt', 'DESC')
.getRawMany();

return rows.map((r) => ({
id: r.id,
courseId: r.courseId,
courseTitle: r.courseTitle,
rating: Number(r.rating),
createdAt: r.createdAt,
}));
}

@Post(':id/penalty')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), RolesGuard)
Expand Down
8 changes: 8 additions & 0 deletions src/submissions/domain/submission-contribution-kind.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum SubmissionContributionKind {
// Ajuda real prestada à comunidade (atividade de voluntariado), conta para
// selos de "contribuição" e para o extrato público de contribuições.
COMMUNITY_ACTIVITY = 'COMMUNITY_ACTIVITY',
// Marco de trilha de aprendizado (prova ou test-out) — XP pessoal, não é
// contribuição para a comunidade e não deve contar para selos desse tipo.
TRACK_PROGRESS = 'TRACK_PROGRESS',
}
8 changes: 8 additions & 0 deletions src/submissions/domain/submission.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { SubmissionStatus } from './submission-status.enum';
import { SubmissionContributionKind } from './submission-contribution-kind.enum';
import { EffortLevel } from '../../activities/domain/effort-level.enum';

export class Submission {
Expand Down Expand Up @@ -36,6 +37,13 @@ export class Submission {
})
isTestOut: boolean;

@ApiProperty({
enum: SubmissionContributionKind,
description:
'Se é uma contribuição real à comunidade (COMMUNITY_ACTIVITY) ou progresso pessoal de trilha (TRACK_PROGRESS)',
})
contributionKind: SubmissionContributionKind;

@ApiProperty({
type: String,
nullable: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from 'typeorm';
import { EntityRelationalHelper } from '../../../../../utils/relational-entity-helper';
import { SubmissionStatus } from '../../../../domain/submission-status.enum';
import { SubmissionContributionKind } from '../../../../domain/submission-contribution-kind.enum';
import { GamificationProfileEntity } from '../../../../../gamification-profiles/infrastructure/persistence/relational/entities/gamification-profile.entity';
import { ActivityEntity } from '../../../../../activities/infrastructure/persistence/relational/entities/activity.entity';
import { UserEntity } from '../../../../../users/infrastructure/persistence/relational/entities/user.entity';
Expand Down Expand Up @@ -46,6 +47,16 @@ export class SubmissionEntity extends EntityRelationalHelper {
@Column({ type: 'boolean', default: false })
isTestOut: boolean;

// Distingue contribuição real à comunidade de progresso pessoal de trilha —
// não pode ser inferido só pela nulidade de trackItemId em regras de
// negócio (selo "Primeira Missão" já contou test-out por isso).
@Column({
type: 'enum',
enum: SubmissionContributionKind,
default: SubmissionContributionKind.COMMUNITY_ACTIVITY,
})
contributionKind: SubmissionContributionKind;

@Column({ type: 'varchar', nullable: true, default: null })
proofUrl: string | null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export class SubmissionMapper {
domainEntity.activityId = raw.activityId;
domainEntity.trackItemId = raw.trackItemId;
domainEntity.isTestOut = raw.isTestOut;
domainEntity.contributionKind = raw.contributionKind;
domainEntity.proofUrl = raw.proofUrl;
domainEntity.description = raw.description;
domainEntity.customTitle = raw.customTitle;
Expand All @@ -33,6 +34,7 @@ export class SubmissionMapper {
persistenceEntity.activityId = domainEntity.activityId;
persistenceEntity.trackItemId = domainEntity.trackItemId;
persistenceEntity.isTestOut = domainEntity.isTestOut;
persistenceEntity.contributionKind = domainEntity.contributionKind;
persistenceEntity.proofUrl = domainEntity.proofUrl;
persistenceEntity.description = domainEntity.description;
persistenceEntity.customTitle = domainEntity.customTitle;
Expand Down
2 changes: 2 additions & 0 deletions src/submissions/submissions.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SubmissionsController } from './submissions.controller';
import { SubmissionsService } from './submissions.service';
import { Submission } from './domain/submission';
import { SubmissionStatus } from './domain/submission-status.enum';
import { SubmissionContributionKind } from './domain/submission-contribution-kind.enum';
import { CreateSubmissionDto } from './dto/create-submission.dto';
import { UpdateSubmissionDto } from './dto/update-submission.dto';
import { FindAllSubmissionsDto } from './dto/find-all-submissions.dto';
Expand All @@ -16,6 +17,7 @@ const mockSubmission: Submission = {
activityId: 'activity-uuid-0001',
trackItemId: null,
isTestOut: false,
contributionKind: SubmissionContributionKind.COMMUNITY_ACTIVITY,
proofUrl: null,
description: null,
customTitle: null,
Expand Down
2 changes: 2 additions & 0 deletions src/submissions/submissions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { LearningTracksService } from '../learning-tracks/learning-tracks.servic
import { AuditLogsService } from '../audit-logs/audit-logs.service';
import { Submission } from './domain/submission';
import { SubmissionStatus } from './domain/submission-status.enum';
import { SubmissionContributionKind } from './domain/submission-contribution-kind.enum';
import { GamificationProfile } from '../gamification-profiles/domain/gamification-profile';
import { Activity } from '../activities/domain/activity';
import { EffortLevel } from '../activities/domain/effort-level.enum';
Expand Down Expand Up @@ -101,6 +102,7 @@ function makeSubmission(overrides: Partial<Submission> = {}): Submission {
activityId: 'activity-1',
trackItemId: null,
isTestOut: false,
contributionKind: SubmissionContributionKind.COMMUNITY_ACTIVITY,
proofUrl: null,
description: null,
customTitle: null,
Expand Down
5 changes: 5 additions & 0 deletions src/submissions/submissions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SubmissionRepository } from './infrastructure/persistence/submission.re
import { IPaginationOptions } from '../utils/types/pagination-options';
import { Submission } from './domain/submission';
import { SubmissionStatus } from './domain/submission-status.enum';
import { SubmissionContributionKind } from './domain/submission-contribution-kind.enum';
import { GamificationProfilesService } from '../gamification-profiles/gamification-profiles.service';
import { ActivitiesService } from '../activities/activities.service';
import { BadgeEvaluatorService } from '../badges/badge-evaluator.service';
Expand Down Expand Up @@ -222,6 +223,9 @@ export class SubmissionsService {
activityId: activity.id,
trackItemId: createSubmissionDto.trackItemId ?? null,
isTestOut,
contributionKind: trackItem
? SubmissionContributionKind.TRACK_PROGRESS
: SubmissionContributionKind.COMMUNITY_ACTIVITY,
proofUrl: createSubmissionDto.proofUrl ?? null,
description: createSubmissionDto.description ?? null,
customTitle: createSubmissionDto.customTitle ?? null,
Expand Down Expand Up @@ -737,6 +741,7 @@ export class SubmissionsService {
activityId: activity.id,
trackItemId: null,
isTestOut: false,
contributionKind: SubmissionContributionKind.COMMUNITY_ACTIVITY,
proofUrl: null,
description: null,
customTitle: null,
Expand Down