feat: marketplace, économie à crédits, perks temps réel & pubs réelles
Transforme XIP en réseau social satirique complet : monnaie fictive, marketplace, cosmétiques visibles de tous, messages riches sandboxés, pubs pilotées par les données, et tous les compteurs mock rendus réels. Backend (Bun + Hono + Prisma + Redis) - Économie par IP : modèles Wallet/Purchase/Entitlement, lib/wallet.ts avec spend() atomique (point unique du paywall) + recharge gratuite. - isLocalhost() → mode gratuit (README « si localhost: pas de paywall »). - Marketplace : lib/catalog.ts (achat transactionnel, stock limité, limites par IP) + routes/shop.ts ; 10 produits seedés (idempotent). - Perks : lib/perks.ts (cache Redis busté à l'achat) ; authorPerks injecté dans les payloads messages + endpoint batch /api/perks ; frame WS « perks » global pour MAJ live des messages déjà affichés. - Messages riches : Message.richMode/richContent, gating par entitlement. - Pubs réelles : modèle Ad seedé avec les 4 pubs (ex-hardcodées), rotation par API, comptage d'impressions réel + réconciliation. - WebSocket : IP capturée par connexion → broadcastToIp / broadcast ; frames wallet/perks/ads/alert. - Pièces jointes : lib/storage.ts (UUID, jamais exécuté) + routes/uploads.ts (limite 1 Mo sauf déblocage/localhost, Content-Disposition: attachment). - Alerte audio : routes/alert.ts (cooldown serveur Redis NX, clamp durée). - Compteur « argent extorqué » réel : impressions×CPM + crédits dépensés. Frontend (Vue 3 + Vite) - /shop : ShopPage + ProductCard fidèles aux maquettes ; composables useWallet/useShop/usePerks/useAds/useAttachments/useAlert. - UI de réponse (bannière + sous-threads), solde + lien Shop dans le header. - Perks rendus : Style Doré (or), Pets autour de l'IP, NoAds masque les pubs. - RichContent.vue : iframe sandbox verrouillée (htmlcss sans script ; js allow-scripts seul, jamais allow-same-origin) + CSP. - AdBand/InlineCasinoAd pilotés par l'API ; barre de saisie avec 📎, compteur de caractères, composer riche et bouton alerte. Infra - Migration economy_ads_attachments_rich ; seed idempotent (produits+pubs). - vite.config : usePolling (HMR fiable sur /mnt/c via WSL). - backend/.gitignore : uploads/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
308
backend/src/lib/catalog.ts
Normal file
308
backend/src/lib/catalog.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { spend, getWallet, InsufficientCreditsError } from "./wallet";
|
||||
import { isLocalhost } from "./ip";
|
||||
import { invalidatePerks, getPerksForIp } from "./perks";
|
||||
|
||||
/**
|
||||
* Marketplace catalogue + purchase engine.
|
||||
*
|
||||
* Prices are centi-credits (mockup € → credits). The server is the ONLY
|
||||
* authority on price, stock, and per-IP limits — the client never decides.
|
||||
*/
|
||||
|
||||
export interface PurchaseOptions {
|
||||
plan?: "monthly" | "annual"; // subscription
|
||||
durationDays?: number; // ad-frame
|
||||
format?: "static" | "gif"; // ad-frame
|
||||
url?: string; // ad-frame destination
|
||||
petDesign?: string; // pet slug
|
||||
petChar?: string; // pet glyph
|
||||
petPosition?: "left" | "right" | "both";
|
||||
}
|
||||
|
||||
export interface PurchaseResult {
|
||||
ok: true;
|
||||
productId: string;
|
||||
pricePaid: number;
|
||||
balance: number;
|
||||
entitlementKinds: string[];
|
||||
}
|
||||
|
||||
export class PurchaseError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.name = "PurchaseError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Effective unit price for a product given options (promo + add-ons). */
|
||||
function effectivePrice(product: any, options: PurchaseOptions): number {
|
||||
let price = product.promoPrice ?? product.basePrice;
|
||||
let meta: any = {};
|
||||
try {
|
||||
meta = product.metaJson ? JSON.parse(product.metaJson) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
|
||||
if (product.kind === "subscription") {
|
||||
const plan = (meta.plans ?? []).find((p: any) => p.id === (options.plan ?? "monthly"));
|
||||
if (plan) price = plan.price;
|
||||
}
|
||||
if (product.kind === "ad-frame") {
|
||||
const dur = (meta.durations ?? []).find(
|
||||
(d: any) => d.days === (options.durationDays ?? 7)
|
||||
);
|
||||
const fmt = (meta.formats ?? []).find(
|
||||
(f: any) => f.id === (options.format ?? "static")
|
||||
);
|
||||
price += (dur?.extra ?? 0) + (fmt?.extra ?? 0);
|
||||
}
|
||||
return price;
|
||||
}
|
||||
|
||||
export async function listProducts(category?: string) {
|
||||
return prisma.product.findMany({
|
||||
where: { active: true, ...(category ? { category } : {}) },
|
||||
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export function getProduct(id: string) {
|
||||
return prisma.product.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
/** All entitlements an IP owns (active), for "Mes achats". */
|
||||
export async function getEntitlements(ip: string) {
|
||||
const now = new Date();
|
||||
const rows = await prisma.entitlement.findMany({
|
||||
where: { ip, active: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now);
|
||||
}
|
||||
|
||||
async function countActiveEntitlements(ip: string, kind: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });
|
||||
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy a product. Enforces per-IP limits + stock, spends credits atomically,
|
||||
* grants the entitlement(s). Returns the new balance and granted kinds.
|
||||
* Side-effect: caller should bust perks cache + broadcast (done in the route).
|
||||
*/
|
||||
export async function purchase(
|
||||
ip: string,
|
||||
productId: string,
|
||||
options: PurchaseOptions = {}
|
||||
): Promise<{ result: PurchaseResult; visiblePerkChanged: boolean; adCreated: boolean }> {
|
||||
const product = await getProduct(productId);
|
||||
if (!product || !product.active) throw new PurchaseError("Produit introuvable", 404);
|
||||
|
||||
const free = isLocalhost(ip);
|
||||
const price = effectivePrice(product, options);
|
||||
|
||||
// Resolve which entitlement kind(s) this grants + per-IP limit checks.
|
||||
const grants: { kind: string; expiresAt?: Date; meta?: any }[] = [];
|
||||
let visiblePerkChanged = false;
|
||||
let adCreated = false;
|
||||
|
||||
switch (product.kind) {
|
||||
case "subscription": {
|
||||
// NoAds: 1 active max.
|
||||
if ((await countActiveEntitlements(ip, "noads")) >= 1)
|
||||
throw new PurchaseError("Tu as déjà un abonnement NoAds actif", 409);
|
||||
const plan = options.plan ?? "monthly";
|
||||
const days = plan === "annual" ? 365 : 30;
|
||||
grants.push({ kind: "noads", expiresAt: new Date(Date.now() + days * DAY_MS), meta: { plan } });
|
||||
break;
|
||||
}
|
||||
case "ip-skin": {
|
||||
// Style Doré: 1 active max + global stock cap.
|
||||
if ((await countActiveEntitlements(ip, "style-dore")) >= 1)
|
||||
throw new PurchaseError("Tu possèdes déjà le Style Doré", 409);
|
||||
grants.push({ kind: "style-dore", meta: { variant: "gold" } });
|
||||
visiblePerkChanged = true;
|
||||
break;
|
||||
}
|
||||
case "pet": {
|
||||
if ((await countActiveEntitlements(ip, "pet")) >= 3)
|
||||
throw new PurchaseError("Maximum 3 pets actifs", 409);
|
||||
const char = options.petChar ?? "♥";
|
||||
grants.push({
|
||||
kind: "pet",
|
||||
meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" },
|
||||
});
|
||||
visiblePerkChanged = true;
|
||||
break;
|
||||
}
|
||||
case "ad-frame": {
|
||||
if ((await countActiveEntitlements(ip, "ad-frame")) >= 1)
|
||||
throw new PurchaseError("Tu as déjà un cadre de pub actif", 409);
|
||||
const days = options.durationDays ?? 7;
|
||||
grants.push({
|
||||
kind: "ad-frame",
|
||||
expiresAt: new Date(Date.now() + days * DAY_MS),
|
||||
meta: { format: options.format ?? "static", url: options.url ?? "", days },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rich": {
|
||||
const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss";
|
||||
if ((await countActiveEntitlements(ip, kind)) >= 1)
|
||||
throw new PurchaseError("Déjà débloqué", 409);
|
||||
grants.push({ kind });
|
||||
break;
|
||||
}
|
||||
case "unlock": {
|
||||
// no-file-limit, element-skin, etc. — slug == kind.
|
||||
if ((await countActiveEntitlements(ip, product.id)) >= 1)
|
||||
throw new PurchaseError("Déjà débloqué", 409);
|
||||
grants.push({ kind: product.id });
|
||||
if (product.id === "element-skin") visiblePerkChanged = false; // viewer-scoped
|
||||
break;
|
||||
}
|
||||
case "consumable": {
|
||||
// audio-alert: grant the entitlement once; firing is a separate action.
|
||||
if ((await countActiveEntitlements(ip, "audio-alert")) < 1) {
|
||||
grants.push({ kind: "audio-alert" });
|
||||
} else {
|
||||
// Already owned — buying again is a harmless top-up; just record it.
|
||||
grants.push({ kind: "audio-alert" });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "bundle": {
|
||||
// Cosmetic bundle: Style Doré + 1 pet.
|
||||
if ((await countActiveEntitlements(ip, "style-dore")) < 1)
|
||||
grants.push({ kind: "style-dore", meta: { variant: "gold" } });
|
||||
if ((await countActiveEntitlements(ip, "pet")) < 3) {
|
||||
const char = options.petChar ?? "★";
|
||||
grants.push({
|
||||
kind: "pet",
|
||||
meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" },
|
||||
});
|
||||
}
|
||||
if (grants.length === 0)
|
||||
throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409);
|
||||
visiblePerkChanged = true;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new PurchaseError("Type de produit non géré", 400);
|
||||
}
|
||||
|
||||
// Stock check for limited products (Style Doré). Done transactionally with the
|
||||
// spend so we can never oversell the 50-unit cap under concurrency.
|
||||
let balance = 0;
|
||||
try {
|
||||
balance = await prisma.$transaction(async (tx) => {
|
||||
if (product.stockLimit != null) {
|
||||
const fresh = await tx.product.findUnique({ where: { id: product.id } });
|
||||
if (!fresh) throw new PurchaseError("Produit introuvable", 404);
|
||||
if (fresh.stockSold >= fresh.stockLimit)
|
||||
throw new PurchaseError("Stock épuisé", 409);
|
||||
await tx.product.update({
|
||||
where: { id: product.id },
|
||||
data: { stockSold: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
// Spend (skips real deduction for localhost free mode).
|
||||
if (!free && price > 0) {
|
||||
const w = await tx.wallet.upsert({
|
||||
where: { ip },
|
||||
create: { ip, balance: 0 },
|
||||
update: {},
|
||||
});
|
||||
if (w.balance < price) throw new InsufficientCreditsError();
|
||||
const updated = await tx.wallet.update({
|
||||
where: { ip },
|
||||
data: { balance: { decrement: price } },
|
||||
});
|
||||
await tx.purchase.create({
|
||||
data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) },
|
||||
});
|
||||
// Grant entitlements inside the tx too.
|
||||
for (const g of grants) {
|
||||
await tx.entitlement.create({
|
||||
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
|
||||
});
|
||||
}
|
||||
return updated.balance;
|
||||
}
|
||||
|
||||
// Free (localhost) or zero-price: record purchase + grants, no deduction.
|
||||
await tx.purchase.create({
|
||||
data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) },
|
||||
});
|
||||
for (const g of grants) {
|
||||
await tx.entitlement.create({
|
||||
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
|
||||
});
|
||||
}
|
||||
const w = await tx.wallet.findUnique({ where: { ip } });
|
||||
return w?.balance ?? 0;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof InsufficientCreditsError)
|
||||
throw new PurchaseError("Crédits insuffisants", 402);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Bump the global credits-spent money counter (outside the tx; best-effort).
|
||||
if (!free && price > 0) {
|
||||
const { redis } = await import("./redis");
|
||||
void redis.incrby("xip:money:credits_spent", price).catch(() => {});
|
||||
}
|
||||
|
||||
// Ad-frame purchase => create a real Ad row that enters rotation (Phase 7).
|
||||
const adGrant = grants.find((g) => g.kind === "ad-frame");
|
||||
if (adGrant) {
|
||||
await prisma.ad
|
||||
.create({
|
||||
data: {
|
||||
brand: "VOTRE PUB",
|
||||
subtitle: "Espace acheté",
|
||||
url: adGrant.meta?.url || null,
|
||||
cta: "VOIR",
|
||||
icon: "📣",
|
||||
tone: "user",
|
||||
kind: "band",
|
||||
weight: 3,
|
||||
active: true,
|
||||
ownerIp: ip,
|
||||
format: adGrant.meta?.format ?? "static",
|
||||
expiresAt: adGrant.expiresAt ?? null,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
adCreated = true;
|
||||
}
|
||||
|
||||
const balanceView = free ? (await getWallet(ip)).balance : balance;
|
||||
|
||||
return {
|
||||
result: {
|
||||
ok: true,
|
||||
productId: product.id,
|
||||
pricePaid: free ? 0 : price,
|
||||
balance: balanceView,
|
||||
entitlementKinds: grants.map((g) => g.kind),
|
||||
},
|
||||
visiblePerkChanged,
|
||||
adCreated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recompute + cache perks after a purchase (caller broadcasts). */
|
||||
export async function refreshPerks(ip: string) {
|
||||
await invalidatePerks(ip);
|
||||
return getPerksForIp(ip);
|
||||
}
|
||||
Reference in New Issue
Block a user