Swaps - Troca de Itens
O sistema de Swap permite que usuários troquem itens entre si de forma segura e justa, com validação de valores e proteção contra fraudes.
Como Funciona
Modelo de Dados
prisma
model Swap {
id BigInt @id @default(autoincrement())
initiatorId BigInt
receiverId BigInt?
status SwapStatus
// Valores
initiatorValueCents BigInt
receiverValueCents BigInt?
valueDifference BigInt?
// Metadados
message String?
expiresAt DateTime
initiator User @relation("SwapInitiator", ...)
receiver User? @relation("SwapReceiver", ...)
initiatorItems SwapItem[] @relation("SwapInitiatorItems")
receiverItems SwapItem[] @relation("SwapReceiverItems")
createdAt DateTime @default(now())
completedAt DateTime?
@@index([initiatorId])
@@index([receiverId])
@@index([status])
}
enum SwapStatus {
PENDING // Aguardando aceite
ACCEPTED // Aceito, processando
COMPLETED // Concluído
REJECTED // Rejeitado
CANCELLED // Cancelado pelo iniciador
EXPIRED // Expirado
}
model SwapItem {
id BigInt @id @default(autoincrement())
swapId BigInt
itemId BigInt
inventoryId BigInt
side SwapSide // INITIATOR ou RECEIVER
valueCents BigInt
swap Swap @relation(...)
item Item @relation(...)
@@index([swapId])
}
enum SwapSide {
INITIATOR
RECEIVER
}Implementação
Criar Swap
typescript
// create-swap.use-case.ts
@Injectable()
export class CreateSwapUseCase {
async execute(
initiatorId: bigint,
dto: CreateSwapDto,
): Promise<Swap> {
const lockKey = `user:${initiatorId}:swap-create`;
return await this.redlock.using([lockKey], 30000, async () => {
// 1. Validar itens do iniciador
const initiatorItems = await this.validateAndLockItems(
initiatorId,
dto.itemIds,
);
// 2. Calcular valor total
const initiatorValue = initiatorItems.reduce(
(sum, item) => sum + item.valueCents,
0n,
);
// 3. Validar limites
if (initiatorValue < MIN_SWAP_VALUE) {
throw new SwapValueTooLowException();
}
return await this.prisma.$transaction(async (tx) => {
// 4. Bloquear itens
for (const item of initiatorItems) {
await tx.userInventory.update({
where: { id: item.inventoryId },
data: { status: 'LOCKED' },
});
}
// 5. Criar swap
const swap = await tx.swap.create({
data: {
initiatorId,
status: SwapStatus.PENDING,
initiatorValueCents: initiatorValue,
message: dto.message,
expiresAt: addHours(new Date(), 24), // 24h para aceitar
},
});
// 6. Registrar itens
for (const item of initiatorItems) {
await tx.swapItem.create({
data: {
swapId: swap.id,
itemId: item.itemId,
inventoryId: item.inventoryId,
side: SwapSide.INITIATOR,
valueCents: item.valueCents,
},
});
}
return swap;
});
});
}
}Aceitar Swap
typescript
// accept-swap.use-case.ts
@Injectable()
export class AcceptSwapUseCase {
async execute(
receiverId: bigint,
swapId: bigint,
dto: AcceptSwapDto,
): Promise<Swap> {
const lockKey = `swap:${swapId}:accept`;
return await this.redlock.using([lockKey], 30000, async () => {
// 1. Buscar swap
const swap = await this.swapRepository.findById(swapId);
// 2. Validar status
if (swap.status !== SwapStatus.PENDING) {
throw new SwapNotPendingException();
}
// 3. Validar expiração
if (new Date() > swap.expiresAt) {
await this.expireSwap(swapId);
throw new SwapExpiredException();
}
// 4. Não pode aceitar próprio swap
if (swap.initiatorId === receiverId) {
throw new CannotAcceptOwnSwapException();
}
// 5. Validar itens do receiver
const receiverItems = await this.validateAndLockItems(
receiverId,
dto.itemIds,
);
// 6. Calcular valor e diferença
const receiverValue = receiverItems.reduce(
(sum, item) => sum + item.valueCents,
0n,
);
const difference = swap.initiatorValueCents - receiverValue;
// 7. Validar diferença máxima (10%)
const maxDifference = swap.initiatorValueCents / 10n;
if (Math.abs(Number(difference)) > Number(maxDifference)) {
throw new SwapValueMismatchException(
`Diferença de valor excede 10%`,
);
}
return await this.prisma.$transaction(async (tx) => {
// 8. Bloquear itens do receiver
for (const item of receiverItems) {
await tx.userInventory.update({
where: { id: item.inventoryId },
data: { status: 'LOCKED' },
});
}
// 9. Registrar itens do receiver
for (const item of receiverItems) {
await tx.swapItem.create({
data: {
swapId: swap.id,
itemId: item.itemId,
inventoryId: item.inventoryId,
side: SwapSide.RECEIVER,
valueCents: item.valueCents,
},
});
}
// 10. Atualizar swap
await tx.swap.update({
where: { id: swapId },
data: {
receiverId,
receiverValueCents: receiverValue,
valueDifference: difference,
status: SwapStatus.ACCEPTED,
},
});
// 11. Executar troca
return await this.executeSwap(tx, swap.id);
});
});
}
private async executeSwap(tx: PrismaTransaction, swapId: bigint): Promise<Swap> {
const swap = await tx.swap.findUnique({
where: { id: swapId },
include: {
initiatorItems: { include: { item: true } },
receiverItems: { include: { item: true } },
},
});
// Mover itens do iniciador para receiver
for (const swapItem of swap.initiatorItems) {
await tx.userInventory.update({
where: { id: swapItem.inventoryId },
data: {
userId: swap.receiverId,
status: 'AVAILABLE',
},
});
}
// Mover itens do receiver para iniciador
for (const swapItem of swap.receiverItems) {
await tx.userInventory.update({
where: { id: swapItem.inventoryId },
data: {
userId: swap.initiatorId,
status: 'AVAILABLE',
},
});
}
// Finalizar swap
return await tx.swap.update({
where: { id: swapId },
data: {
status: SwapStatus.COMPLETED,
completedAt: new Date(),
},
});
}
}Rejeitar/Cancelar Swap
typescript
// reject-swap.use-case.ts
async execute(userId: bigint, swapId: bigint): Promise<Swap> {
const swap = await this.swapRepository.findById(swapId);
// Validar que user pode rejeitar
if (swap.initiatorId !== userId && swap.receiverId !== userId) {
throw new UnauthorizedException();
}
// Validar status
if (!['PENDING', 'ACCEPTED'].includes(swap.status)) {
throw new InvalidSwapStatusException();
}
return await this.prisma.$transaction(async (tx) => {
// Desbloquear itens do iniciador
for (const item of swap.initiatorItems) {
await tx.userInventory.update({
where: { id: item.inventoryId },
data: { status: 'AVAILABLE' },
});
}
// Desbloquear itens do receiver (se houver)
for (const item of swap.receiverItems) {
await tx.userInventory.update({
where: { id: item.inventoryId },
data: { status: 'AVAILABLE' },
});
}
// Atualizar status
const newStatus = swap.initiatorId === userId
? SwapStatus.CANCELLED
: SwapStatus.REJECTED;
return await tx.swap.update({
where: { id: swapId },
data: { status: newStatus },
});
});
}Frontend
Tela de Criação de Swap
tsx
const CreateSwapPage = () => {
const [selectedItems, setSelectedItems] = useState<Item[]>([]);
const [message, setMessage] = useState('');
const { inventory } = useInventory();
const totalValue = selectedItems.reduce(
(sum, item) => sum + item.valueCents,
0,
);
const handleCreate = async () => {
const result = await api.post('/swaps', {
itemIds: selectedItems.map((i) => i.id),
message,
});
router.push(`/swaps/${result.data.id}`);
};
return (
<div className="create-swap">
<h1>Criar Troca</h1>
{/* Inventário do usuário */}
<div className="inventory-grid">
{inventory.map((item) => (
<ItemCard
key={item.id}
item={item}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
</div>
{/* Itens selecionados */}
<div className="selected-items">
<h3>Itens para troca</h3>
<div className="items-row">
{selectedItems.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</div>
<p>Valor total: R$ {(totalValue / 100).toFixed(2)}</p>
</div>
{/* Mensagem opcional */}
<textarea
placeholder="Mensagem para o outro usuário..."
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<Button onClick={handleCreate} disabled={selectedItems.length === 0}>
Criar Troca
</Button>
</div>
);
};Tela de Aceite
tsx
const AcceptSwapPage = ({ swap }: { swap: Swap }) => {
const [myItems, setMyItems] = useState<Item[]>([]);
const { inventory } = useInventory();
const myValue = myItems.reduce((sum, item) => sum + item.valueCents, 0);
const difference = swap.initiatorValueCents - myValue;
const percentDiff = (Number(difference) / Number(swap.initiatorValueCents)) * 100;
const isValidDifference = Math.abs(percentDiff) <= 10;
return (
<div className="accept-swap">
{/* Itens do iniciador */}
<div className="initiator-items">
<h3>{swap.initiator.username} oferece:</h3>
<div className="items-row">
{swap.initiatorItems.map((swapItem) => (
<ItemCard key={swapItem.id} item={swapItem.item} />
))}
</div>
<p>Valor: R$ {(swap.initiatorValueCents / 100).toFixed(2)}</p>
</div>
{/* Meus itens */}
<div className="my-items">
<h3>Seus itens:</h3>
<div className="inventory-grid">
{inventory.map((item) => (
<ItemCard
key={item.id}
item={item}
selected={myItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
</div>
<p>Valor: R$ {(myValue / 100).toFixed(2)}</p>
</div>
{/* Diferença */}
<div className={cn(
"difference",
isValidDifference ? "text-green-500" : "text-red-500"
)}>
<p>Diferença: {percentDiff.toFixed(1)}%</p>
{!isValidDifference && (
<p className="text-sm">Diferença máxima permitida: 10%</p>
)}
</div>
<div className="actions">
<Button variant="destructive" onClick={() => rejectSwap(swap.id)}>
Rejeitar
</Button>
<Button
onClick={() => acceptSwap(swap.id, myItems)}
disabled={!isValidDifference || myItems.length === 0}
>
Aceitar Troca
</Button>
</div>
</div>
);
};Endpoints da API
Criar Swap
http
POST /api/swaps
Authorization: Bearer {sessionId}
Content-Type: application/json
{
"itemIds": ["123", "456"],
"message": "Troca justa!"
}Listar Swaps
http
GET /api/swaps?status=PENDING&page=1Aceitar Swap
http
POST /api/swaps/:id/accept
Content-Type: application/json
{
"itemIds": ["789", "101"]
}Rejeitar/Cancelar
http
POST /api/swaps/:id/rejecthttp
POST /api/swaps/:id/cancelValidações
typescript
// swap-validation.service.ts
validateSwap(
initiatorItems: Item[],
receiverItems: Item[],
): ValidationResult {
const errors: string[] = [];
// 1. Mínimo de itens
if (initiatorItems.length === 0 || receiverItems.length === 0) {
errors.push('Ambos os lados devem ter pelo menos 1 item');
}
// 2. Valor mínimo
const MIN_VALUE = 1000n; // R$ 10
const initiatorValue = sum(initiatorItems);
const receiverValue = sum(receiverItems);
if (initiatorValue < MIN_VALUE || receiverValue < MIN_VALUE) {
errors.push('Valor mínimo de R$ 10 por lado');
}
// 3. Diferença máxima (10%)
const difference = initiatorValue - receiverValue;
const maxDiff = initiatorValue / 10n;
if (Math.abs(Number(difference)) > Number(maxDiff)) {
errors.push('Diferença de valor não pode exceder 10%');
}
// 4. Itens disponíveis
const unavailable = [...initiatorItems, ...receiverItems].filter(
(i) => i.status !== 'AVAILABLE',
);
if (unavailable.length > 0) {
errors.push('Alguns itens não estão disponíveis');
}
return { valid: errors.length === 0, errors };
}Expiração Automática
typescript
// swap-expiration.scheduler.ts
@Cron('*/5 * * * *') // A cada 5 minutos
async expireOldSwaps() {
const expiredSwaps = await this.swapRepository.findExpired();
for (const swap of expiredSwaps) {
await this.prisma.$transaction(async (tx) => {
// Desbloquear itens
for (const item of swap.initiatorItems) {
await tx.userInventory.update({
where: { id: item.inventoryId },
data: { status: 'AVAILABLE' },
});
}
// Atualizar status
await tx.swap.update({
where: { id: swap.id },
data: { status: SwapStatus.EXPIRED },
});
});
// Notificar iniciador
await this.notificationService.send(swap.initiatorId, {
type: 'SWAP_EXPIRED',
message: 'Sua proposta de troca expirou',
});
}
}Troubleshooting
Itens Travados
typescript
// Verificar itens com status LOCKED sem swap ativo
const lockedItems = await inventoryRepository.findByStatus('LOCKED');
for (const item of lockedItems) {
const swap = await swapRepository.findByInventoryId(item.id);
if (!swap || ['COMPLETED', 'REJECTED', 'CANCELLED', 'EXPIRED'].includes(swap.status)) {
// Desbloquear item órfão
await inventoryRepository.update(item.id, { status: 'AVAILABLE' });
console.log(`Desbloqueado item órfão: ${item.id}`);
}
}