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
25 changes: 23 additions & 2 deletions docs/02 - produto/feedback/feedback-reputacao.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,36 @@ genérico e destruiria a comparabilidade entre perfis.
|---|---|---|
| `COMUNICA_IMPEDIMENTOS` | 🗣️ Avisa antes de travar | Comunicou bloqueio ou atraso com antecedência útil |
| `ENTREGA_O_COMBINADO` | 📦 Entrega o que assume | Cumpriu o escopo da stack que assumiu |
| `ENTREGA_NO_PRAZO` | ⏱️ Entrega no prazo | Cumpriu o prazo acordado, não apenas o escopo |
| `APOIA_A_EQUIPE` | 🤝 Puxa a equipe junto | Ajudou colega, revisou trabalho de outro, destravou alguém |
| `PERGUNTA_ANTES_DE_DECIDIR` | 🧭 Pergunta antes de decidir | Não inventou regra de negócio por conta própria |
| `RECEBE_REVISAO_BEM` | 🔄 Recebe revisão bem | Absorveu crítica técnica sem travar o fluxo do time |
| `AUTONOMIA_NA_STACK` | 🛠️ Resolve sem ser conduzido | Avançou na própria stack sem precisar ser conduzido |
| `RECEBE_REVISAO` | 🔄 Recebe revisão sem travar o time | Absorveu crítica técnica e seguiu, sem parar o fluxo |
| `ADAPTA_A_MUDANCA` | 🛠️ Se ajusta quando o plano muda | Seguiu produzindo quando escopo ou prazo mudaram |

**Regra dos rótulos:** todo selo começa com verbo na terceira pessoa e descreve
ação. É proibido rótulo com superlativo (*"Mestre em..."*), com traço inato
(*"...nato"*) ou que implique escala (*"nível avançado em..."*). Superlativo
pressupõe hierarquia, e hierarquia é ranking.

#### Eixos avaliados e não incluídos — 19/08/2026

Registrado para não serem repropostos sem contexto.

| Proposta | Por que ficou de fora |
|---|---|
| **Criatividade** | Não testemunhável — como se comprova? Julgamento de criatividade carrega viés documentado: a mesma ideia é lida como criativa ou estranha conforme quem propôs |
| **Disciplina** | Sinônimo de comprometimento no uso corrente, já coberto por *Entrega o que assume*. Carrega julgamento moral |
| **Trabalha bem em equipe** | Já coberto por *Puxa a equipe junto*, que diz a mesma coisa como ação observada em vez de qualidade da pessoa |
| **Comprometimento** | Idem — *Entrega o que assume* é a versão testemunhável |
| **Domínio da stack** | "Domínio" implica escala, e escala é nível. O portfólio já exibe as stacks assumidas por projeto — isso é registro factual, não precisa de atestado |
| **Aprende rápido** | Único eixo comparativo proposto: rápido em relação a quem? Fere o princípio 6 com mais força que qualquer outro, porque quem está começando *está aprendendo por definição*. Há ainda o efeito perverso de que "aprende rápido" costuma significar "já sabia" |
| **Encara stack que não conhecia** | Versão comportamental do anterior, e viável. Ficou de fora para não passar de sete eixos — com limite de 3, cada eixo a mais dilui a escolha. Primeiro candidato a entrar se algum eixo se mostrar morto pela métrica da seção 17 |
| **Autonomia na stack** *(existia na v2)* | Substituído por *Se ajusta quando o plano muda*. A frase original — "Resolve sem ser conduzido" — definia a pessoa pela ausência de ajuda e colidia com *Pergunta antes de decidir*, podendo ensinar a quem chega que pedir ajuda custa um selo |

> 💡 **Critério que guiou a lista:** adjetivo é opinião, comportamento é
> testemunho. Como aval profissional, *"Entrega o que assume · 5 pessoas ·
> 3 projetos"* vale mais que *"é comprometida"* — todo perfil se diz
> comprometido, e recrutador desconta adjetivo.

### 5.2 Como se avalia

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- CreateEnum
CREATE TYPE "FeedbackAttrEnum" AS ENUM ('COMUNICA_IMPEDIMENTOS', 'ENTREGA_O_COMBINADO', 'APOIA_A_EQUIPE', 'PERGUNTA_ANTES_DE_DECIDIR', 'RECEBE_REVISAO_BEM', 'AUTONOMIA_NA_STACK');

-- AlterTable
ALTER TABLE "Feedback" ADD COLUMN "hiddenByAdmin" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "hiddenReason" TEXT,
ADD COLUMN "publicationAllowed" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "publishedByReceiver" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "rating" DROP NOT NULL,
ALTER COLUMN "anonymous" SET DEFAULT true;

-- CreateTable
CREATE TABLE "FeedbackAttribute" (
"id" TEXT NOT NULL,
"feedbackId" TEXT NOT NULL,
"attribute" "FeedbackAttrEnum" NOT NULL,

CONSTRAINT "FeedbackAttribute_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "FeedbackAttribute_attribute_idx" ON "FeedbackAttribute"("attribute");

-- CreateIndex
CREATE UNIQUE INDEX "FeedbackAttribute_feedbackId_attribute_key" ON "FeedbackAttribute"("feedbackId", "attribute");

-- CreateIndex
CREATE INDEX "Feedback_toUserId_hiddenByAdmin_idx" ON "Feedback"("toUserId", "hiddenByAdmin");

-- CreateIndex
CREATE UNIQUE INDEX "Feedback_projectId_fromUserId_toUserId_key" ON "Feedback"("projectId", "fromUserId", "toUserId");

-- AddForeignKey
ALTER TABLE "FeedbackAttribute" ADD CONSTRAINT "FeedbackAttribute_feedbackId_fkey" FOREIGN KEY ("feedbackId") REFERENCES "Feedback"("id") ON DELETE CASCADE ON UPDATE CASCADE;

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- AlterEnum
BEGIN;
CREATE TYPE "FeedbackAttrEnum_new" AS ENUM ('COMUNICA_IMPEDIMENTOS', 'ENTREGA_O_COMBINADO', 'ENTREGA_NO_PRAZO', 'APOIA_A_EQUIPE', 'PERGUNTA_ANTES_DE_DECIDIR', 'RECEBE_REVISAO', 'ADAPTA_A_MUDANCA');
ALTER TABLE "FeedbackAttribute" ALTER COLUMN "attribute" TYPE "FeedbackAttrEnum_new" USING ("attribute"::text::"FeedbackAttrEnum_new");
ALTER TYPE "FeedbackAttrEnum" RENAME TO "FeedbackAttrEnum_old";
ALTER TYPE "FeedbackAttrEnum_new" RENAME TO "FeedbackAttrEnum";
DROP TYPE "public"."FeedbackAttrEnum_old";
COMMIT;

69 changes: 60 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,67 @@ model Feedback {
projectId String
fromUserId String
toUserId String
rating Int
comment String?
anonymous Boolean
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stackTakenId String?
fromUser User @relation("FeedbackGiver", fields: [fromUserId], references: [id])
project Project @relation(fields: [projectId], references: [id])
stackTaken StackTaken? @relation(fields: [stackTakenId], references: [id])
toUser User @relation("FeedbackReceiver", fields: [toUserId], references: [id])

// Camada B — texto de desenvolvimento. Vive na área privada do avaliado.
// Só é publicado se o avaliador permitir na origem (publicationAllowed) E o
// avaliado decidir publicar (publishedByReceiver). As duas condições valem.
comment String?
publicationAllowed Boolean @default(false)
publishedByReceiver Boolean @default(false)

// Moderação — ver seção 16 do documento de produto.
hiddenByAdmin Boolean @default(false)
hiddenReason String?

// Camada A — corroborações por eixo comportamental.
attributes FeedbackAttribute[]

// Legado. `rating` deixa de ser exibido e agregado publicamente; permanece
// como sinal operacional interno consultável por ADMIN. `anonymous` perde
// função de produto — o anonimato público passou a ser regra fixa.
// Código novo não deve ler nenhum dos dois. Ver seção 14.
rating Int?
anonymous Boolean @default(true)

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

fromUser User @relation("FeedbackGiver", fields: [fromUserId], references: [id])
project Project @relation(fields: [projectId], references: [id])
stackTaken StackTaken? @relation(fields: [stackTakenId], references: [id])
toUser User @relation("FeedbackReceiver", fields: [toUserId], references: [id])

// Uma avaliação por par de pessoas em cada projeto.
@@unique([projectId, fromUserId, toUserId])
// Suporta a agregação do portfólio público.
@@index([toUserId, hiddenByAdmin])
}

// Tabela relacional em vez de array de enum: só ela impede, no banco, que o
// mesmo eixo seja contado duas vezes na mesma avaliação. Como o valor público
// da corroboração é a contagem, dado duplicado corrompe o que a funcionalidade
// entrega. Decidido na spike de 19/08/2026.
model FeedbackAttribute {
id String @id @default(cuid())
feedbackId String
attribute FeedbackAttrEnum
feedback Feedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)

@@unique([feedbackId, attribute])
@@index([attribute])
}

// Lista fechada por decisão de produto: eixo livre viraria elogio genérico e
// destruiria a comparabilidade entre perfis. Ver seção 5.1.
enum FeedbackAttrEnum {
COMUNICA_IMPEDIMENTOS
ENTREGA_O_COMBINADO
ENTREGA_NO_PRAZO
APOIA_A_EQUIPE
PERGUNTA_ANTES_DE_DECIDIR
RECEBE_REVISAO
ADAPTA_A_MUDANCA
}

model Invite {
Expand Down
57 changes: 29 additions & 28 deletions src/components/Portfolio/sections/PortfolioFeedbackSection.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,48 @@
import { Star } from 'lucide-react';
import { MessageSquareQuote } from 'lucide-react';
import type { PortfolioPublicResponse } from '@/types/portfolio.types';
import { Section } from '@/components/ui/section';

interface PortfolioFeedbackSectionProps {
data: PortfolioPublicResponse;
}

/**
* Camada B do sistema de feedback: texto de desenvolvimento publicado pelo
* próprio avaliado.
*
* As estrelas foram removidas por decisão de produto (seção 5.4 — antipadrões
* visuais proibidos): nota e escala transformam a página em ranking, e o
* documento é explícito em que a camada pública não comunica quantidade nem
* teto. Aqui o texto vale por si.
*
* A autoria não é exibida: o anonimato público deixou de ser escolha do
* avaliador e passou a ser regra fixa (seção 7).
*/
export function PortfolioFeedbackSection({
data,
}: PortfolioFeedbackSectionProps) {
if (!data.feedback || data.feedback.length === 0) return null;
const comentarios = (data.feedback ?? []).filter((fb) => fb.comment);

if (comentarios.length === 0) return null;

return (
<Section icon={<Star className="h-4 w-4" />} title="Feedback recebido">
<Section
icon={<MessageSquareQuote className="h-4 w-4" />}
title="Feedback recebido"
>
<div className="space-y-3">
{data.feedback.map((fb) => (
<div
{comentarios.map((fb) => (
<figure
key={fb.id}
className="space-y-2 rounded-lg border border-border bg-card p-4"
>
<div className="flex items-center justify-between">
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`h-3.5 w-3.5 ${
i < fb.rating
? 'fill-amber-400 text-amber-400'
: 'text-muted-foreground/30'
}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">
{fb.givenBy ?? 'Avaliação anônima'} · {fb.projectName}
</span>
</div>
{fb.comment && (
<p className="text-sm leading-relaxed text-muted-foreground">
&quot;{fb.comment}&quot;
</p>
)}
</div>
<blockquote className="text-sm leading-relaxed text-muted-foreground">
&quot;{fb.comment}&quot;
</blockquote>
<figcaption className="text-xs text-muted-foreground">
{fb.projectName}
</figcaption>
</figure>
))}
</div>
</Section>
Expand Down
17 changes: 6 additions & 11 deletions src/lib/portfolio.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@ export async function getPublicPortfolio(
if (user.showProjects) response.projects = Array.from(projectMap.values());
if (user.showCertificates) response.certificates = user.certificates;
if (user.showFeedback) {
// `rating` deixou de ser exposto publicamente — seção 14 do documento de
// produto. A camada pública passa a ser as corroborações por eixo; o texto
// é complemento, nunca evidência quantificada.
response.feedback = user.feedbacksReceived.map((fb) => ({
id: fb.id,
rating: fb.rating,
comment: fb.comment,
projectName: fb.project.name,
givenBy: fb.anonymous ? null : fb.fromUser.name,
Expand Down Expand Up @@ -303,9 +305,6 @@ export async function listPortfolioSummary(
skill: { select: { id: true, name: true, iconUrl: true } },
},
},
feedbacksReceived: {
select: { rating: true },
},
},
});

Expand All @@ -315,12 +314,9 @@ export async function listPortfolioSummary(

return {
items: page.map((u) => {
const avgFeedback =
u.feedbacksReceived.length > 0
? u.feedbacksReceived.reduce((sum, fb) => sum + fb.rating, 0) /
u.feedbacksReceived.length
: null;

// A média pública de ratings foi revogada na v2 do documento de produto:
// duas médias lado a lado são um ranking, independentemente do rótulo.
// Ver seções 3 (princípio 2) e 14.
return {
username: u.userName!,
name: u.name,
Expand All @@ -329,7 +325,6 @@ export async function listPortfolioSummary(
role: u.role,
github: u.showGithub ? u.github : null,
linkedin: u.showLinkedin ? u.linkedin : null,
feedback: u.showFeedback ? avgFeedback : null,
skills: u.skills.map((us) => us.skill),
};
}),
Expand Down
6 changes: 3 additions & 3 deletions src/types/portfolio.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,11 @@ export interface PortfolioCertificate {

export interface PortfolioFeedback {
id: string;
rating: number;
comment: string | null;
projectName: string;
givenBy: string | null; // null quando anonymous = true
/** Sempre null na camada pública: o anonimato deixou de ser escolha do
* avaliador e passou a ser regra fixa. Ver seção 7 do documento de produto. */
givenBy: string | null;
}

export interface PortfolioSummaryItem {
Expand All @@ -74,7 +75,6 @@ export interface PortfolioSummaryItem {
// Campos do summary (opcionais — só presentes no /summary)
github?: string | null;
linkedin?: string | null;
feedback?: number | null;
skills: Array<{ id: string; name: string; iconUrl: string | null }>;
}

Expand Down
Loading