feat: enhance purchase intentions with chat integration and offer tracking

- Added MongoDB chat/message integration for seller responses
- Improved seller response validation and default messaging
- Integrated automatic chat creation/update when responding to intentions
- Added highest custom offer fields to listing queries and models
- Enhanced listing data with top offer information for buyers
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-08 11:51:21 -03:00
1 parent d27526e026
commit d8e7d5d7e6
19 files changed
+806 -58

No files matched your search

+2
View File
@@ -31,6 +31,8 @@ function extractToken(request: NextRequest): string | null {
}
/**
* Converte payload de token em objeto AuthenticatedUser.
*/
function toAuthenticatedUser(payload: {
userId: number;
username: string;
@@ -0,0 +1,17 @@
-- Migration 020: Custom offer fields for purchase intentions
-- Criado em: 2025-02-07
ALTER TABLE purchase_intentions
ADD COLUMN IF NOT EXISTS custom_offer_amount NUMERIC(12, 2),
ADD COLUMN IF NOT EXISTS custom_offer_currency VARCHAR(20),
ADD COLUMN IF NOT EXISTS custom_offer_created_at TIMESTAMP WITHOUT TIME ZONE;
-- Atualizar registros existentes para evitar valores inconsistentes
UPDATE purchase_intentions
SET custom_offer_amount = NULL,
custom_offer_currency = NULL,
custom_offer_created_at = NULL
WHERE custom_offer_amount IS NOT NULL
OR custom_offer_currency IS NOT NULL
OR custom_offer_created_at IS NOT NULL;
+2 -1
View File
@@ -1,4 +1,4 @@
/**
/**
* Script de execução de migrations
* Executa todas as migrations SQL em ordem
*/
@@ -39,6 +39,7 @@ const MIGRATIONS = [
"017_add_items_foreign_key.sql",
"018_migrate_listings_to_unified_items.sql",
"019_subscription_tiers.sql",
"020_purchase_intentions_custom_offer.sql",
];
async function runMigrations() {
+15 -9
View File
@@ -43,6 +43,9 @@ export interface PurchaseIntention {
buyer_id: number;
payment_method: string;
payment_method_change_count: number;
custom_offer_amount: number | string | null;
custom_offer_currency: string | null;
custom_offer_created_at: Date | string | null;
chat_id: string | null;
status: PurchaseIntentionStatus;
buyer_message: string | null;
@@ -61,17 +64,12 @@ interface PurchaseIntentionsResponse {
count: number;
}
/**
* Hook para buscar intenções de compra do usuário autenticado
* Atualiza automaticamente a cada 30 segundos
*/
export function useMyPurchaseIntentions(status?: PurchaseIntentionStatus) {
export function useMyPurchaseIntentions() {
const queryParams = new URLSearchParams();
if (status) queryParams.set("status", status);
queryParams.set("limit", "100"); // Limite alto para mostrar todas
queryParams.set("limit", "100");
const { data, isLoading, error, refetch } = useQuery<PurchaseIntentionsResponse>({
queryKey: ["my-purchase-intentions", status],
queryKey: ["my-purchase-intentions"],
queryFn: async () => {
const res = await fetch(`/api/purchase-intentions/my?${queryParams.toString()}`);
if (!res.ok) {
@@ -89,8 +87,16 @@ export function useMyPurchaseIntentions(status?: PurchaseIntentionStatus) {
enabled: true, // Sempre habilitado (autenticação é verificada no backend)
});
const normalizedIntentions = (data?.intentions || []).map((intention) => ({
...intention,
custom_offer_amount:
intention.custom_offer_amount !== null && intention.custom_offer_amount !== undefined
? Number(intention.custom_offer_amount)
: null,
}));
return {
intentions: data?.intentions || [],
intentions: normalizedIntentions,
count: data?.count || 0,
isLoading,
error,
+1 -1
View File
@@ -1,4 +1,4 @@
/**
/**
* Queries de itens usando Prisma
* Busca itens da tabela unificada items (PostgreSQL)
*/
+32
View File
@@ -5,6 +5,10 @@
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
import {
getHighestCustomOfferForListing,
getHighestCustomOffersForListings,
} from "./purchase-intentions";
// Re-exportar tipos para compatibilidade
export type ListingStatus = "active" | "sold" | "cancelled" | "in_escrow" | "expired";
@@ -42,6 +46,8 @@ export interface Listing {
initial_price_tibia_coins?: number | null;
initial_price_gold_coins?: number | null;
seller_character_name?: string | null;
highest_custom_offer_amount?: number | null;
highest_custom_offer_currency?: string | null;
}
export interface CreateListingData {
@@ -141,6 +147,8 @@ export async function createListing(
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
highest_custom_offer_amount: null,
highest_custom_offer_currency: null,
} as Listing;
}
@@ -165,6 +173,8 @@ export async function getListingById(listingId: number): Promise<Listing | null>
},
});
const highestOffer = await getHighestCustomOfferForListing(listingId);
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
@@ -175,6 +185,8 @@ export async function getListingById(listingId: number): Promise<Listing | null>
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
highest_custom_offer_amount: highestOffer?.amount ?? null,
highest_custom_offer_currency: highestOffer?.currency ?? null,
} as Listing;
}
@@ -264,9 +276,14 @@ export async function getListings(
}
}
const highestOffers = await getHighestCustomOffersForListings(
listings.map((listing) => listing.listing_id)
);
// Converter todos os campos Decimal e BigInt para números JavaScript
const listingsWithCharacterNames = listings.map((listing) => {
const key = `${listing.seller_id}:${listing.server_name}`;
const highestOffer = highestOffers.get(listing.listing_id);
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
@@ -276,6 +293,8 @@ export async function getListings(
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: charactersMap.get(key) || null,
highest_custom_offer_amount: highestOffer?.amount ?? null,
highest_custom_offer_currency: highestOffer?.currency ?? null,
} as Listing;
});
@@ -319,6 +338,8 @@ export async function updateListing(
},
});
const highestOffer = await getHighestCustomOfferForListing(listing.listing_id);
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
@@ -329,6 +350,8 @@ export async function updateListing(
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
highest_custom_offer_amount: highestOffer?.amount ?? null,
highest_custom_offer_currency: highestOffer?.currency ?? null,
} as Listing;
}
@@ -486,9 +509,14 @@ export async function getActiveListings(
}
}
const highestOffers = await getHighestCustomOffersForListings(
listings.map((listing) => listing.listing_id)
);
// Converter todos os campos Decimal e BigInt para números JavaScript
const listingsWithCharacterNames = listings.map((listing) => {
const key = `${listing.seller_id}:${listing.server_name}`;
const highestOffer = highestOffers.get(listing.listing_id);
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
@@ -498,6 +526,8 @@ export async function getActiveListings(
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: charactersMap.get(key) || null,
highest_custom_offer_amount: highestOffer?.amount ?? null,
highest_custom_offer_currency: highestOffer?.currency ?? null,
} as Listing;
});
@@ -580,6 +610,8 @@ export async function placeBid(
price_gold_coins: updatedListing.price_gold_coins ? Number(updatedListing.price_gold_coins) : null,
initial_price_gold_coins: updatedListing.initial_price_gold_coins ? Number(updatedListing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
highest_custom_offer_amount: null,
highest_custom_offer_currency: null,
} as Listing,
previousBidderId: previousBidderId || undefined,
};
+122 -14
View File
@@ -3,6 +3,7 @@
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
export type PurchaseIntentionStatus = "pending" | "accepted" | "rejected" | "expired" | "completed";
export type PaymentMethod = "pix" | "tibia_coins" | "gold_coins" | "trade";
@@ -13,6 +14,8 @@ export interface CreatePurchaseIntentionData {
payment_method: PaymentMethod;
buyer_message?: string;
expires_at?: Date;
custom_offer_amount?: number | null;
custom_offer_currency?: PaymentMethod | null;
}
export interface PurchaseIntention {
@@ -21,6 +24,9 @@ export interface PurchaseIntention {
buyer_id: number;
payment_method: string;
payment_method_change_count: number;
custom_offer_amount: number | null;
custom_offer_currency: string | null;
custom_offer_created_at: Date | null;
status: PurchaseIntentionStatus;
buyer_message: string | null;
seller_response: string | null;
@@ -42,13 +48,27 @@ export interface PurchaseIntention {
};
}
function serializePurchaseIntention(intention: any): PurchaseIntention {
if (!intention) return intention;
return {
...intention,
custom_offer_amount:
intention.custom_offer_amount !== null && intention.custom_offer_amount !== undefined
? Number(intention.custom_offer_amount)
: null,
custom_offer_currency: intention.custom_offer_currency ?? null,
custom_offer_created_at: intention.custom_offer_created_at ?? null,
};
}
/**
* Cria uma nova intenção de compra
*/
export async function createPurchaseIntention(
data: CreatePurchaseIntentionData
): Promise<PurchaseIntention> {
return await prisma.purchaseIntention.create({
const result = await prisma.purchaseIntention.create({
data: {
listing_id: data.listing_id,
buyer_id: data.buyer_id,
@@ -56,6 +76,15 @@ export async function createPurchaseIntention(
buyer_message: data.buyer_message || null,
expires_at: data.expires_at || null,
status: "pending",
custom_offer_amount:
data.custom_offer_amount !== undefined && data.custom_offer_amount !== null
? new Prisma.Decimal(data.custom_offer_amount)
: null,
custom_offer_currency: data.custom_offer_currency || null,
custom_offer_created_at:
data.custom_offer_amount !== undefined && data.custom_offer_amount !== null
? new Date()
: null,
},
include: {
listing: {
@@ -73,7 +102,9 @@ export async function createPurchaseIntention(
},
},
},
}) as PurchaseIntention;
});
return serializePurchaseIntention(result);
}
/**
@@ -90,7 +121,7 @@ export async function getPurchaseIntentionsByListing(
where.status = status;
}
return await prisma.purchaseIntention.findMany({
const intentions = await prisma.purchaseIntention.findMany({
where,
include: {
listing: {
@@ -110,7 +141,9 @@ export async function getPurchaseIntentionsByListing(
},
orderBy: { created_at: "desc" },
take: limit,
}) as PurchaseIntention[];
});
return intentions.map(serializePurchaseIntention);
}
/**
@@ -127,7 +160,7 @@ export async function getPurchaseIntentionsByBuyer(
where.status = status;
}
return await prisma.purchaseIntention.findMany({
const intentions = await prisma.purchaseIntention.findMany({
where,
include: {
listing: {
@@ -147,7 +180,9 @@ export async function getPurchaseIntentionsByBuyer(
},
orderBy: { created_at: "desc" },
take: limit,
}) as PurchaseIntention[];
});
return intentions.map(serializePurchaseIntention);
}
/**
@@ -156,7 +191,7 @@ export async function getPurchaseIntentionsByBuyer(
export async function getPurchaseIntentionById(
intentionId: number
): Promise<PurchaseIntention | null> {
return await prisma.purchaseIntention.findUnique({
const intention = await prisma.purchaseIntention.findUnique({
where: { intention_id: intentionId },
include: {
listing: {
@@ -174,7 +209,9 @@ export async function getPurchaseIntentionById(
},
},
},
}) as PurchaseIntention | null;
});
return serializePurchaseIntention(intention);
}
/**
@@ -200,7 +237,7 @@ export async function updatePurchaseIntentionStatus(
updateData.seller_response = sellerResponse;
}
return await prisma.purchaseIntention.update({
const updated = await prisma.purchaseIntention.update({
where: { intention_id: intentionId },
data: updateData,
include: {
@@ -219,7 +256,9 @@ export async function updatePurchaseIntentionStatus(
},
},
},
}) as PurchaseIntention;
});
return serializePurchaseIntention(updated);
}
/**
@@ -259,7 +298,7 @@ export async function getPendingIntentionByBuyerAndListing(
listingId: number,
buyerId: number
): Promise<PurchaseIntention | null> {
return await prisma.purchaseIntention.findFirst({
const intention = await prisma.purchaseIntention.findFirst({
where: {
listing_id: listingId,
buyer_id: buyerId,
@@ -284,7 +323,9 @@ export async function getPendingIntentionByBuyerAndListing(
orderBy: {
created_at: "desc",
},
}) as PurchaseIntention | null;
});
return serializePurchaseIntention(intention);
}
/**
@@ -314,7 +355,7 @@ export async function updatePurchaseIntentionPaymentMethod(
}
// Atualizar
return await prisma.purchaseIntention.update({
const updated = await prisma.purchaseIntention.update({
where: { intention_id: intentionId },
data: {
payment_method: newPaymentMethod,
@@ -337,7 +378,9 @@ export async function updatePurchaseIntentionPaymentMethod(
},
},
},
}) as PurchaseIntention;
});
return serializePurchaseIntention(updated);
}
/**
@@ -372,3 +415,68 @@ export async function getPendingIntentionListingIdsForBuyer(
return Array.from(uniqueIds);
}
export async function getHighestCustomOfferForListing(
listingId: number
): Promise<{ amount: number; currency: string } | null> {
const offer = await prisma.purchaseIntention.findFirst({
where: {
listing_id: listingId,
status: { in: ["pending", "accepted"] },
custom_offer_amount: { not: null },
custom_offer_currency: { not: null },
},
orderBy: { custom_offer_amount: "desc" },
select: {
custom_offer_amount: true,
custom_offer_currency: true,
},
});
if (!offer) {
return null;
}
return {
amount: Number(offer.custom_offer_amount),
currency: offer.custom_offer_currency!,
};
}
export async function getHighestCustomOffersForListings(
listingIds: number[]
): Promise<Map<number, { amount: number; currency: string }>> {
if (listingIds.length === 0) {
return new Map();
}
const offers = await prisma.purchaseIntention.findMany({
where: {
listing_id: { in: listingIds },
status: { in: ["pending", "accepted"] },
custom_offer_amount: { not: null },
custom_offer_currency: { not: null },
},
select: {
listing_id: true,
custom_offer_amount: true,
custom_offer_currency: true,
},
orderBy: [
{ listing_id: "asc" },
{ custom_offer_amount: "desc" },
],
});
const result = new Map<number, { amount: number; currency: string }>();
for (const offer of offers) {
if (!result.has(offer.listing_id)) {
result.set(offer.listing_id, {
amount: Number(offer.custom_offer_amount),
currency: offer.custom_offer_currency!,
});
}
}
return result;
}