diff --git a/README.md b/README.md index 436d279..131f5fa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # Operation Whiskers -Classified +Classified \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..3fc42be 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "src/generated/**", ]), ]); diff --git a/prisma/migrations/20251208_add_wagering/migration.sql b/prisma/migrations/20251208_add_wagering/migration.sql new file mode 100644 index 0000000..3b145df --- /dev/null +++ b/prisma/migrations/20251208_add_wagering/migration.sql @@ -0,0 +1,57 @@ +-- CreateEnum +CREATE TYPE "WagerStatus" AS ENUM ('open', 'active', 'pending_verification', 'resolved', 'cancelled'); + +-- CreateEnum +CREATE TYPE "VerificationType" AS ENUM ('subjective', 'deadline', 'photo_proof'); + +-- CreateTable +CREATE TABLE "Wager" ( + "id" TEXT NOT NULL, + "conversationId" TEXT NOT NULL, + "creatorPhone" TEXT NOT NULL, + "title" TEXT NOT NULL, + "condition" TEXT NOT NULL, + "sides" TEXT[], + "verificationType" "VerificationType" NOT NULL, + "verificationConfig" JSONB, + "status" "WagerStatus" NOT NULL DEFAULT 'open', + "deadline" TIMESTAMP(3), + "result" TEXT, + "resolvedAt" TIMESTAMP(3), + "proofUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Wager_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WagerPosition" ( + "id" TEXT NOT NULL, + "wagerId" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "side" TEXT NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "matchedAmount" DECIMAL(10,2) NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WagerPosition_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Wager_conversationId_status_idx" ON "Wager"("conversationId", "status"); + +-- CreateIndex +CREATE INDEX "Wager_deadline_idx" ON "Wager"("deadline"); + +-- CreateIndex +CREATE INDEX "WagerPosition_wagerId_idx" ON "WagerPosition"("wagerId"); + +-- CreateIndex +CREATE INDEX "WagerPosition_phone_idx" ON "WagerPosition"("phone"); + +-- AddForeignKey +ALTER TABLE "WagerPosition" ADD CONSTRAINT "WagerPosition_wagerId_fkey" FOREIGN KEY ("wagerId") REFERENCES "Wager"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Add wageringEnabled flag to GroupChatSettings +ALTER TABLE "GroupChatSettings" ADD COLUMN "wageringEnabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 27a755e..d32d39f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -340,6 +340,9 @@ model GroupChatSettings { // Users can describe how they want the AI to behave via natural language customPrompt String? @db.Text + // Wagering feature toggle - enables/disables wagering functionality for this group + wageringEnabled Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -362,3 +365,64 @@ model FeatureRequest { @@index([createdAt]) @@index([phoneNumber]) } + +// ============================================ +// Group Chat Wagering +// ============================================ + +enum WagerStatus { + open // Market open, accepting positions (may have unmatched bets) + active // At least some amount matched, wager in progress + pending_verification // Deadline passed, awaiting resolution + resolved // Winner declared, payouts calculated + cancelled // Cancelled before any matching +} + +enum VerificationType { + subjective // Group vote or honor system + deadline // Auto-resolves when time expires + photo_proof // Requires photo evidence +} + +model Wager { + id String @id @default(cuid()) + conversationId String // Group chat ID + creatorPhone String // Who proposed (doesn't have to bet) + + title String // "Pizza Arrival" + condition String // "Arrives before 7:45 PM" + sides String[] // e.g. ["YES", "NO"] or ["UNDER", "OVER"] + + verificationType VerificationType + verificationConfig Json? // { deadline?: string, ... } + + status WagerStatus @default(open) + deadline DateTime? // When betting closes / event happens + result String? // Winning side + resolvedAt DateTime? + proofUrl String? + + positions WagerPosition[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([conversationId, status]) + @@index([deadline]) +} + +model WagerPosition { + id String @id @default(cuid()) + wagerId String + wager Wager @relation(fields: [wagerId], references: [id], onDelete: Cascade) + + phone String // Who placed this position + side String // Which side they're on + amount Decimal @db.Decimal(10, 2) // Total amount wagered + matchedAmount Decimal @db.Decimal(10, 2) @default(0) // Amount matched against other side + + createdAt DateTime @default(now()) + + @@index([wagerId]) + @@index([phone]) +} diff --git a/src/ai/agents/factory.ts b/src/ai/agents/factory.ts index 92e423a..8d67b00 100644 --- a/src/ai/agents/factory.ts +++ b/src/ai/agents/factory.ts @@ -17,6 +17,8 @@ export function defaultBuildContext(context: ConversationContext): string { systemState: context.systemState, groupParticipants: context.groupParticipants, sender: context.sender, + groupChatCustomPrompt: context.groupChatCustomPrompt, + wageringEnabled: context.wageringEnabled, }); } diff --git a/src/ai/agents/systemPrompt.ts b/src/ai/agents/systemPrompt.ts index 76fc20d..fae138b 100644 --- a/src/ai/agents/systemPrompt.ts +++ b/src/ai/agents/systemPrompt.ts @@ -184,6 +184,7 @@ export function buildConversationContextPrompt(context: { groupParticipants?: GroupParticipantInfo[] | null; sender?: string; // The authenticated sender (for group chats) groupChatCustomPrompt?: string | null; // Custom behavior prompt for this group chat + wageringEnabled?: boolean; // Whether wagering/betting is enabled for this group chat }): string { const parts: string[] = []; @@ -225,6 +226,9 @@ export function buildConversationContextPrompt(context: { parts.push( "- GROUP CHAT ETIQUETTE: Do NOT spam. Respond in 1 message (max 2). Often a reaction is better than a message. You do NOT need to respond to everything.", ); + if (context.wageringEnabled) { + parts.push("- Wagering/betting is enabled for this group chat. Use the relevant tools if participants are discussing bets or wagers."); + } // Custom group chat behavior (if configured) if (context.groupChatCustomPrompt) { diff --git a/src/ai/respondToMessage.ts b/src/ai/respondToMessage.ts index 63e47e6..4e7a731 100644 --- a/src/ai/respondToMessage.ts +++ b/src/ai/respondToMessage.ts @@ -11,6 +11,7 @@ import { createRequestResearchTool, createScheduledJobTools, createUpdateUserContextTool, + createWagerTools, } from "@/src/ai/tools"; import { hasActiveConnections } from "@/src/ai/tools/helpers"; import type { ConversationContext } from "@/src/db/conversation"; @@ -89,10 +90,11 @@ export async function respondToMessage( createGenerateConnectionLinkTool(context); } - // Add group chat-only tools (settings management) + // Add group chat-only tools (settings management, wagering) if (context.isGroup) { const groupTools = createGroupChatSettingsTools(context); - Object.assign(contextBoundTools, groupTools); + const wagerTools = createWagerTools(context); + Object.assign(contextBoundTools, groupTools, wagerTools); } // Conditionally merge integration tools only if user has connections diff --git a/src/ai/tools/index.ts b/src/ai/tools/index.ts index a3b28ff..b1b3718 100644 --- a/src/ai/tools/index.ts +++ b/src/ai/tools/index.ts @@ -10,6 +10,7 @@ * - Create and manage scheduled jobs * - Configure group chat settings * - Record feature requests from users + * - Orchestrate wagers/bets in group chats * * Security: User-identity-sensitive tools are context-bound factories. * The phone number is taken from authenticated context, not user input, @@ -25,6 +26,7 @@ export { createRequestFeatureTool } from "./requestFeature"; export { createRequestResearchTool } from "./requestResearch"; export { createUpdateUserContextTool } from "./updateUserContext"; export { createScheduledJobTools } from "./scheduledJob"; +export { createWagerTools } from "./wager"; // Static tools (no context needed - Claude can see images directly) export { createImageTool } from "./createImage"; diff --git a/src/ai/tools/wager.ts b/src/ai/tools/wager.ts new file mode 100644 index 0000000..2fda411 --- /dev/null +++ b/src/ai/tools/wager.ts @@ -0,0 +1,538 @@ +/** + * AI Tools for Group Chat Wagering + * + * Allows Whiskers to orchestrate wagers/bets among friends in group chats. + * Supports multi-participant positions with matching. + * + * Security: Phone number is taken from authenticated context, not user input. + */ + +import type { ConversationContext } from "@/src/db/conversation"; +import { + createWager, + placePosition, + resolveWager, + cancelWager, + getActiveWagersForGroup, + getWagerWithPositions, + getWagerSummary, + calculatePayouts, + type VerificationType, +} from "@/src/db/wager"; +import { + getVerificationMethodsDescription, + validateVerificationConfig, + isReadyForResolution, +} from "@/src/lib/wager"; +import { tool, zodSchema } from "ai"; +import { z } from "zod"; + +/** + * Create context-bound wager tools + * Only available in group chats + */ +export function createWagerTools(context: ConversationContext) { + // Wager tools are only available in group chats with wagering enabled + if (!context.isGroup || !context.wageringEnabled) { + return {}; + } + + const conversationId = context.conversationId; + const senderPhone = context.sender; + + return { + /** + * Tool for proposing a new wager/market + */ + proposeWager: tool({ + description: + "Create a NEW wager/betting market for the group. " + + "Use this when someone wants to bet on an outcome. " + + "The creator doesn't have to take a position - they can just create the market. " + + "Choose the appropriate verification type based on the bet:\n" + + getVerificationMethodsDescription(), + inputSchema: zodSchema( + z.object({ + title: z + .string() + .describe( + "Short, catchy title for the wager (e.g., 'Pizza Arrival', 'Game Winner')", + ), + condition: z + .string() + .describe( + "Clear description of what's being bet on (e.g., 'Pizza arrives before 7:45 PM')", + ), + sides: z + .array(z.string()) + .length(2) + .optional() + .default(["YES", "NO"]) + .describe( + "The two sides of the bet. Defaults to ['YES', 'NO']. " + + "Can be custom like ['UNDER', 'OVER'] or ['TEAM_A', 'TEAM_B']", + ), + verificationType: z + .enum(["subjective", "deadline", "photo_proof"]) + .describe( + "How the wager will be verified. " + + "Choose based on the nature of the bet.", + ), + deadline: z + .iso + .datetime() + .optional() + .describe( + "ISO datetime for when the event happens or betting closes. " + + "Required for deadline verification, optional for others.", + ), + creatorSide: z + .string() + .optional() + .describe( + "If the creator wants to take a position, which side they're on. " + + "Leave empty if creator is just creating the market without betting.", + ), + creatorAmount: z + .number() + .positive() + .optional() + .describe( + "If the creator is taking a position, how much they're betting. " + + "Required if creatorSide is specified.", + ), + }), + ), + execute: async ({ + title, + condition, + sides, + verificationType, + deadline, + creatorSide, + creatorAmount, + }) => { + try { + if (!senderPhone) { + return { + success: false, + message: "Could not identify who is proposing the wager.", + }; + } + + // Validate verification config + const verificationConfig = deadline ? { deadline } : undefined; + const configValidation = validateVerificationConfig( + verificationType as VerificationType, + verificationConfig, + ); + + if (!configValidation.valid) { + return { + success: false, + message: configValidation.error || "Invalid verification config", + }; + } + + // Validate creator position if specified + if (creatorSide && !sides.includes(creatorSide)) { + return { + success: false, + message: `Invalid side: ${creatorSide}. Must be one of: ${sides.join(", ")}`, + }; + } + + if (creatorSide && !creatorAmount) { + return { + success: false, + message: "Must specify amount if taking a position", + }; + } + + // Create the wager + const wager = await createWager({ + conversationId, + creatorPhone: senderPhone, + title, + condition, + sides, + verificationType: verificationType as VerificationType, + verificationConfig: configValidation.config, + deadline: deadline ? new Date(deadline) : undefined, + }); + + // Place creator's position if they're betting + if (creatorSide && creatorAmount) { + await placePosition({ + wagerId: wager.id, + phone: senderPhone, + side: creatorSide, + amount: creatorAmount, + }); + } + + // Get updated wager with positions + const updated = await getWagerWithPositions(wager.id); + + return { + success: true, + message: + `New wager created: "${title}" ` + + (creatorSide + ? `with ${senderPhone} on ${creatorSide} for $${creatorAmount}` + : "(creator not betting)"), + wager: { + id: wager.id, + title, + condition, + sides, + verificationType, + deadline, + status: updated?.status || "open", + }, + }; + } catch (error) { + return { + success: false, + message: `Failed to create wager: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }; + } + }, + }), + + /** + * Tool for taking a position on a wager + */ + takePosition: tool({ + description: + "Take a position (place a bet) on an existing wager. " + + "Multiple people can be on the same side. " + + "Bets are matched against the opposite side - if there's no one on the other side yet, " + + "the position is unmatched until someone takes the other side.", + inputSchema: zodSchema( + z.object({ + wagerId: z.string().describe("The ID of the wager to bet on"), + side: z + .string() + .describe("Which side to take (e.g., 'YES', 'NO', 'UNDER', 'OVER')"), + amount: z.number().positive().describe("How much to bet (in dollars)"), + }), + ), + execute: async ({ wagerId, side, amount }) => { + try { + if (!senderPhone) { + return { + success: false, + message: "Could not identify who is placing the bet.", + }; + } + + const { position, wager } = await placePosition({ + wagerId, + phone: senderPhone, + side, + amount, + }); + + const summary = getWagerSummary(wager); + const matched = position.matchedAmount; + const unmatched = amount - matched; + + let statusMessage = `${senderPhone} bet $${amount} on ${side}. `; + if (matched > 0 && unmatched > 0) { + statusMessage += `$${matched} matched, $${unmatched} waiting for takers.`; + } else if (matched > 0) { + statusMessage += `Fully matched!`; + } else { + statusMessage += `Waiting for someone to take the other side.`; + } + + return { + success: true, + message: statusMessage, + wager: { + id: wager.id, + title: wager.title, + status: wager.status, + totalPool: summary.totalPool, + matchedPool: summary.matchedPool, + }, + position: { + side, + amount, + matched, + unmatched, + }, + }; + } catch (error) { + return { + success: false, + message: `Failed to place bet: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }; + } + }, + }), + + /** + * Tool for listing active wagers in the group + */ + listWagers: tool({ + description: + "List all active wagers in this group chat. " + + "Shows open and active wagers with their current positions.", + inputSchema: zodSchema(z.object({})), + execute: async () => { + try { + const wagers = await getActiveWagersForGroup(conversationId); + + if (wagers.length === 0) { + return { + success: true, + message: "No active wagers in this group.", + wagers: [], + }; + } + + const wagerSummaries = wagers.map((w) => { + const summary = getWagerSummary(w); + return { + id: w.id, + title: w.title, + condition: w.condition, + sides: w.sides, + status: w.status, + deadline: w.deadline?.toISOString(), + verificationType: w.verificationType, + totalPool: summary.totalPool, + matchedPool: summary.matchedPool, + sideTotals: summary.sideTotals, + }; + }); + + return { + success: true, + message: `Found ${wagers.length} active wager(s).`, + wagers: wagerSummaries, + }; + } catch (error) { + return { + success: false, + message: `Failed to list wagers: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }; + } + }, + }), + + /** + * Tool for resolving a wager + */ + resolveWager: tool({ + description: + "Resolve a wager by declaring the winning side. " + + "Use this when the outcome is determined. " + + "For photo_proof wagers, include the proof URL. " + + "Calculates and announces payouts.", + inputSchema: zodSchema( + z.object({ + wagerId: z.string().describe("The ID of the wager to resolve"), + winningSide: z.string().describe("The winning side of the bet"), + proofUrl: z + .url() + .optional() + .describe("URL to photo proof (for photo_proof verification type)"), + }), + ), + execute: async ({ wagerId, winningSide, proofUrl }) => { + try { + // Get the wager to check verification status + const wager = await getWagerWithPositions(wagerId); + if (!wager) { + return { + success: false, + message: "Wager not found.", + }; + } + + // Check if ready for resolution + const readyCheck = isReadyForResolution( + wager.verificationType, + wager.verificationConfig, + ); + + // For deadline wagers, check if we should allow early resolution + if ( + wager.verificationType === "deadline" && + !readyCheck.ready && + !proofUrl + ) { + return { + success: false, + message: `Cannot resolve yet: ${readyCheck.reason}. ` + + `Provide photo proof for early resolution.`, + }; + } + + // Resolve the wager + const resolved = await resolveWager(wagerId, winningSide, proofUrl); + + // Calculate payouts + const payouts = calculatePayouts(resolved); + + const payoutMessages = payouts + .map((p) => `${p.phone}: $${p.amount.toFixed(2)}`) + .join(", "); + + return { + success: true, + message: + `Wager "${resolved.title}" resolved! ` + + `Winner: ${winningSide}. ` + + (payouts.length > 0 + ? `Payouts: ${payoutMessages}` + : "No payouts (no matched bets)."), + wager: { + id: resolved.id, + title: resolved.title, + result: resolved.result, + status: resolved.status, + }, + payouts, + }; + } catch (error) { + return { + success: false, + message: `Failed to resolve wager: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }; + } + }, + }), + + /** + * Tool for cancelling an unmatched wager + */ + cancelWager: tool({ + description: + "Cancel a wager that hasn't been matched yet. " + + "Only works if no positions have been matched. " + + "All unmatched bets are returned.", + inputSchema: zodSchema( + z.object({ + wagerId: z.string().describe("The ID of the wager to cancel"), + }), + ), + execute: async ({ wagerId }) => { + try { + // Verify the requester is the creator or check other authorization + const wager = await getWagerWithPositions(wagerId); + if (!wager) { + return { + success: false, + message: "Wager not found.", + }; + } + + // Only creator can cancel (or could expand to any participant) + if (wager.creatorPhone !== senderPhone) { + return { + success: false, + message: "Only the wager creator can cancel it.", + }; + } + + const cancelled = await cancelWager(wagerId); + + return { + success: true, + message: `Wager "${cancelled.title}" has been cancelled. All bets returned.`, + wager: { + id: cancelled.id, + title: cancelled.title, + status: cancelled.status, + }, + }; + } catch (error) { + return { + success: false, + message: `Failed to cancel wager: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }; + } + }, + }), + + /** + * Tool for getting details on a specific wager + */ + getWagerDetails: tool({ + description: + "Get detailed information about a specific wager, including all positions and payouts.", + inputSchema: zodSchema( + z.object({ + wagerId: z.string().describe("The ID of the wager"), + }), + ), + execute: async ({ wagerId }) => { + try { + const wager = await getWagerWithPositions(wagerId); + if (!wager) { + return { + success: false, + message: "Wager not found.", + }; + } + + const summary = getWagerSummary(wager); + const payouts = + wager.status === "resolved" ? calculatePayouts(wager) : null; + + return { + success: true, + wager: { + id: wager.id, + title: wager.title, + condition: wager.condition, + sides: wager.sides, + status: wager.status, + deadline: wager.deadline?.toISOString(), + verificationType: wager.verificationType, + result: wager.result, + resolvedAt: wager.resolvedAt?.toISOString(), + proofUrl: wager.proofUrl, + creatorPhone: wager.creatorPhone, + createdAt: wager.createdAt.toISOString(), + }, + summary: { + totalPool: summary.totalPool, + matchedPool: summary.matchedPool, + sideTotals: summary.sideTotals, + }, + positions: wager.positions.map((p) => ({ + phone: p.phone, + side: p.side, + amount: p.amount, + matched: p.matchedAmount, + unmatched: p.amount - p.matchedAmount, + })), + payouts, + }; + } catch (error) { + return { + success: false, + message: `Failed to get wager details: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }; + } + }, + }), + }; +} diff --git a/src/db/conversation.ts b/src/db/conversation.ts index f478c33..f172b72 100644 --- a/src/db/conversation.ts +++ b/src/db/conversation.ts @@ -5,7 +5,10 @@ import type { } from "@/src/ai/agents/systemPrompt"; import prisma from "@/src/db/client"; import { getUserConnections } from "@/src/db/connection"; -import { getGroupChatCustomPrompt } from "@/src/db/groupChatSettings"; +import { + getGroupChatCustomPrompt, + getGroupChatWageringEnabled, +} from "@/src/db/groupChatSettings"; import { getUserContextByPhone, updateUserContext } from "@/src/db/userContext"; import type { Prisma } from "@/src/generated/prisma"; import { getUserTimezoneFromCalendar } from "@/src/lib/integrations/calendar"; @@ -66,6 +69,7 @@ export interface ConversationContext { groupParticipants?: GroupParticipantInfo[] | null; // Identity info for group participants recentAttachments?: string[]; // URLs of recent image attachments from the conversation (most recent first) groupChatCustomPrompt?: string | null; // Custom prompt for this group chat (injected into system prompt) + wageringEnabled?: boolean; // Whether wagering/betting is enabled for this group chat } /** @@ -382,6 +386,7 @@ export async function getConversationMessages( let systemState: SystemState | null = null; let groupParticipants: GroupParticipantInfo[] | null = null; let groupChatCustomPrompt: string | null = null; + let wageringEnabled = false; // For group chats, look up participant identities and custom prompt if (conversation.isGroup && conversation.participants.length > 0) { @@ -415,12 +420,15 @@ export async function getConversationMessages( ); } - // Fetch group chat custom prompt + // Fetch group chat settings (custom prompt and wagering) try { - groupChatCustomPrompt = await getGroupChatCustomPrompt(conversationId); + [groupChatCustomPrompt, wageringEnabled] = await Promise.all([ + getGroupChatCustomPrompt(conversationId), + getGroupChatWageringEnabled(conversationId), + ]); } catch (error) { console.warn( - `[getConversationMessages] Failed to fetch group chat custom prompt:`, + `[getConversationMessages] Failed to fetch group chat settings:`, error, ); } @@ -549,6 +557,7 @@ export async function getConversationMessages( recentAttachments: recentAttachments.length > 0 ? recentAttachments : undefined, groupChatCustomPrompt, + wageringEnabled, }, }; } diff --git a/src/db/groupChatSettings.ts b/src/db/groupChatSettings.ts index 59392dc..9849db2 100644 --- a/src/db/groupChatSettings.ts +++ b/src/db/groupChatSettings.ts @@ -42,6 +42,40 @@ export async function setGroupChatCustomPrompt( }); } +/** + * Get whether wagering is enabled for a group chat + * Returns false if no settings exist + */ +export async function getGroupChatWageringEnabled( + conversationId: string, +): Promise { + const settings = await prisma.groupChatSettings.findUnique({ + where: { conversationId }, + select: { wageringEnabled: true }, + }); + + return settings?.wageringEnabled ?? false; +} + +/** + * Set or update the wagering enabled flag for a group chat + */ +export async function setGroupChatWageringEnabled( + conversationId: string, + wageringEnabled: boolean, +): Promise { + await prisma.groupChatSettings.upsert({ + where: { conversationId }, + create: { + conversationId, + wageringEnabled, + }, + update: { + wageringEnabled, + }, + }); +} + /** * Delete group chat settings (clears custom prompt) */ diff --git a/src/db/wager.ts b/src/db/wager.ts new file mode 100644 index 0000000..ae55d8f --- /dev/null +++ b/src/db/wager.ts @@ -0,0 +1,552 @@ +/** + * Database operations for Wagers + * + * Handles CRUD operations for group chat wagering/betting markets. + * Supports multi-participant positions with matching. + */ + +import prisma from "@/src/db/client"; +import type { + VerificationType as PrismaVerificationType, + WagerStatus as PrismaWagerStatus, + Prisma, +} from "@/src/generated/prisma"; +import { Decimal } from "@/src/generated/prisma/runtime/library"; + +// ============================================ +// Types +// ============================================ + +export type WagerStatus = PrismaWagerStatus; +export type VerificationType = PrismaVerificationType; + +export interface Wager { + id: string; + conversationId: string; + creatorPhone: string; + title: string; + condition: string; + sides: string[]; + verificationType: VerificationType; + verificationConfig: unknown | null; + status: WagerStatus; + deadline: Date | null; + result: string | null; + resolvedAt: Date | null; + proofUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface WagerPosition { + id: string; + wagerId: string; + phone: string; + side: string; + amount: number; + matchedAmount: number; + createdAt: Date; +} + +export interface WagerWithPositions extends Wager { + positions: WagerPosition[]; +} + +export interface CreateWagerInput { + conversationId: string; + creatorPhone: string; + title: string; + condition: string; + sides?: string[]; + verificationType: VerificationType; + verificationConfig?: unknown; + deadline?: Date; +} + +export interface PlacePositionInput { + wagerId: string; + phone: string; + side: string; + amount: number; +} + +// ============================================ +// CRUD Operations +// ============================================ + +/** + * Create a new wager (market) + */ +export async function createWager(input: CreateWagerInput): Promise { + const wager = await prisma.wager.create({ + data: { + conversationId: input.conversationId, + creatorPhone: input.creatorPhone, + title: input.title, + condition: input.condition, + sides: input.sides || ["YES", "NO"], + verificationType: input.verificationType, + verificationConfig: input.verificationConfig as Prisma.InputJsonValue, + deadline: input.deadline, + status: "open", + }, + }); + + return formatWager(wager); +} + +/** + * Get a wager by ID + */ +export async function getWager(wagerId: string): Promise { + const wager = await prisma.wager.findUnique({ + where: { id: wagerId }, + }); + + return wager ? formatWager(wager) : null; +} + +/** + * Get a wager with all positions + */ +export async function getWagerWithPositions( + wagerId: string, +): Promise { + const wager = await prisma.wager.findUnique({ + where: { id: wagerId }, + include: { positions: true }, + }); + + if (!wager) return null; + + return { + ...formatWager(wager), + positions: wager.positions.map(formatPosition), + }; +} + +/** + * Get all wagers for a group chat + */ +export async function getWagersForGroup( + conversationId: string, + options?: { + status?: WagerStatus | WagerStatus[]; + includePositions?: boolean; + }, +): Promise { + const statusFilter = options?.status + ? Array.isArray(options.status) + ? { in: options.status } + : options.status + : undefined; + + const wagers = await prisma.wager.findMany({ + where: { + conversationId, + ...(statusFilter && { status: statusFilter }), + }, + include: { positions: options?.includePositions ?? true }, + orderBy: { createdAt: "desc" }, + }); + + return wagers.map((w) => ({ + ...formatWager(w), + positions: w.positions.map(formatPosition), + })); +} + +/** + * Get active wagers (open or active) for a group + */ +export async function getActiveWagersForGroup( + conversationId: string, +): Promise { + return getWagersForGroup(conversationId, { + status: ["open", "active"], + includePositions: true, + }); +} + +/** + * Place a position (bet) on a wager + * Automatically matches against opposite side positions + */ +export async function placePosition( + input: PlacePositionInput, +): Promise<{ position: WagerPosition; wager: WagerWithPositions }> { + const { wagerId, phone, side, amount } = input; + + // Get the wager with positions + const wager = await prisma.wager.findUnique({ + where: { id: wagerId }, + include: { positions: true }, + }); + + if (!wager) { + throw new Error("Wager not found"); + } + + if (!["open", "active"].includes(wager.status)) { + throw new Error(`Cannot place position on wager with status: ${wager.status}`); + } + + if (!wager.sides.includes(side)) { + throw new Error(`Invalid side: ${side}. Valid sides: ${wager.sides.join(", ")}`); + } + + // Create the position + const position = await prisma.wagerPosition.create({ + data: { + wagerId, + phone, + side, + amount: new Decimal(amount), + matchedAmount: new Decimal(0), + }, + }); + + // Match against opposite side positions + await matchPositions(wagerId); + + // Get updated wager + const updatedWager = await getWagerWithPositions(wagerId); + if (!updatedWager) { + throw new Error("Failed to get updated wager"); + } + + // Find the updated position (matchedAmount may have changed after matching) + const updatedPosition = updatedWager.positions.find((p) => p.id === position.id); + if (!updatedPosition) { + throw new Error("Failed to find updated position"); + } + + return { + position: updatedPosition, + wager: updatedWager, + }; +} + +/** + * Match positions on a wager + * Called after a new position is placed + */ +async function matchPositions(wagerId: string): Promise { + const wager = await prisma.wager.findUnique({ + where: { id: wagerId }, + include: { positions: true }, + }); + + if (!wager || wager.sides.length !== 2) return; + + const [side1, side2] = wager.sides; + + // Get unmatched amounts for each side + const side1Positions = wager.positions.filter((p) => p.side === side1); + const side2Positions = wager.positions.filter((p) => p.side === side2); + + const side1Unmatched = side1Positions.reduce( + (sum, p) => sum.plus(p.amount.minus(p.matchedAmount)), + new Decimal(0), + ); + const side2Unmatched = side2Positions.reduce( + (sum, p) => sum.plus(p.amount.minus(p.matchedAmount)), + new Decimal(0), + ); + + // Match up to the minimum of unmatched amounts + const matchAmount = Decimal.min(side1Unmatched, side2Unmatched); + + if (matchAmount.lte(0)) return; + + // Distribute matching to positions (FIFO - oldest first) + let remaining = matchAmount; + + // Match side1 positions + for (const pos of side1Positions.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + )) { + if (remaining.lte(0)) break; + + const posUnmatched = pos.amount.minus(pos.matchedAmount); + if (posUnmatched.lte(0)) continue; + + const toMatch = Decimal.min(posUnmatched, remaining); + await prisma.wagerPosition.update({ + where: { id: pos.id }, + data: { matchedAmount: pos.matchedAmount.plus(toMatch) }, + }); + remaining = remaining.minus(toMatch); + } + + // Match side2 positions + remaining = matchAmount; + for (const pos of side2Positions.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + )) { + if (remaining.lte(0)) break; + + const posUnmatched = pos.amount.minus(pos.matchedAmount); + if (posUnmatched.lte(0)) continue; + + const toMatch = Decimal.min(posUnmatched, remaining); + await prisma.wagerPosition.update({ + where: { id: pos.id }, + data: { matchedAmount: pos.matchedAmount.plus(toMatch) }, + }); + remaining = remaining.minus(toMatch); + } + + // Update wager status to active if any matching occurred + if (matchAmount.gt(0) && wager.status === "open") { + await prisma.wager.update({ + where: { id: wagerId }, + data: { status: "active" }, + }); + } +} + +/** + * Resolve a wager with a winning side + */ +export async function resolveWager( + wagerId: string, + winningSide: string, + proofUrl?: string, +): Promise { + const wager = await prisma.wager.findUnique({ + where: { id: wagerId }, + include: { positions: true }, + }); + + if (!wager) { + throw new Error("Wager not found"); + } + + if (!["open", "active", "pending_verification"].includes(wager.status)) { + throw new Error(`Cannot resolve wager with status: ${wager.status}`); + } + + if (!wager.sides.includes(winningSide)) { + throw new Error( + `Invalid winning side: ${winningSide}. Valid sides: ${wager.sides.join(", ")}`, + ); + } + + const updated = await prisma.wager.update({ + where: { id: wagerId }, + data: { + status: "resolved", + result: winningSide, + resolvedAt: new Date(), + proofUrl, + }, + include: { positions: true }, + }); + + return { + ...formatWager(updated), + positions: updated.positions.map(formatPosition), + }; +} + +/** + * Cancel a wager (only if no matching has occurred) + */ +export async function cancelWager(wagerId: string): Promise { + const wager = await prisma.wager.findUnique({ + where: { id: wagerId }, + include: { positions: true }, + }); + + if (!wager) { + throw new Error("Wager not found"); + } + + // Check if any positions have been matched + const hasMatched = wager.positions.some((p) => p.matchedAmount.gt(0)); + + if (hasMatched) { + throw new Error( + "Cannot cancel wager with matched positions. Some bets have already been locked in.", + ); + } + + const updated = await prisma.wager.update({ + where: { id: wagerId }, + data: { status: "cancelled" }, + }); + + return formatWager(updated); +} + +/** + * Update wager status to pending_verification (e.g., when deadline passes) + */ +export async function markWagerPendingVerification( + wagerId: string, +): Promise { + const updated = await prisma.wager.update({ + where: { id: wagerId }, + data: { status: "pending_verification" }, + }); + + return formatWager(updated); +} + +/** + * Get summary of a wager for display + */ +export function getWagerSummary(wager: WagerWithPositions): { + totalPool: number; + matchedPool: number; + sideTotals: Record; +} { + const sideTotals: Record< + string, + { total: number; matched: number; participants: string[] } + > = {}; + + for (const side of wager.sides) { + sideTotals[side] = { total: 0, matched: 0, participants: [] }; + } + + for (const pos of wager.positions) { + if (!sideTotals[pos.side]) { + sideTotals[pos.side] = { total: 0, matched: 0, participants: [] }; + } + sideTotals[pos.side].total += pos.amount; + sideTotals[pos.side].matched += pos.matchedAmount; + if (!sideTotals[pos.side].participants.includes(pos.phone)) { + sideTotals[pos.side].participants.push(pos.phone); + } + } + + const totalPool = Object.values(sideTotals).reduce( + (sum, s) => sum + s.total, + 0, + ); + const matchedPool = Object.values(sideTotals).reduce( + (sum, s) => sum + s.matched, + 0, + ); + + return { totalPool, matchedPool, sideTotals }; +} + +/** + * Calculate payouts for a resolved wager + */ +export function calculatePayouts( + wager: WagerWithPositions, +): { phone: string; amount: number }[] { + if (wager.status !== "resolved" || !wager.result) { + return []; + } + + const winningSide = wager.result; + const losingSide = wager.sides.find((s) => s !== winningSide); + + if (!losingSide) return []; + + // Total matched pool from losing side goes to winning side + const losingPool = wager.positions + .filter((p) => p.side === losingSide) + .reduce((sum, p) => sum + p.matchedAmount, 0); + + const winningPositions = wager.positions.filter( + (p) => p.side === winningSide && p.matchedAmount > 0, + ); + const totalWinningMatched = winningPositions.reduce( + (sum, p) => sum + p.matchedAmount, + 0, + ); + + if (totalWinningMatched === 0) return []; + + // Distribute proportionally + const payouts: { phone: string; amount: number }[] = []; + + for (const pos of winningPositions) { + const share = pos.matchedAmount / totalWinningMatched; + const winnings = losingPool * share; + // They get back their matched amount plus winnings + const totalPayout = pos.matchedAmount + winnings; + payouts.push({ phone: pos.phone, amount: totalPayout }); + } + + // Unmatched amounts go back to their owners + for (const pos of wager.positions) { + const unmatched = pos.amount - pos.matchedAmount; + if (unmatched > 0) { + const existing = payouts.find((p) => p.phone === pos.phone); + if (existing) { + existing.amount += unmatched; + } else { + payouts.push({ phone: pos.phone, amount: unmatched }); + } + } + } + + return payouts; +} + +// ============================================ +// Helpers +// ============================================ + +function formatWager(wager: { + id: string; + conversationId: string; + creatorPhone: string; + title: string; + condition: string; + sides: string[]; + verificationType: PrismaVerificationType; + verificationConfig: unknown; + status: PrismaWagerStatus; + deadline: Date | null; + result: string | null; + resolvedAt: Date | null; + proofUrl: string | null; + createdAt: Date; + updatedAt: Date; +}): Wager { + return { + id: wager.id, + conversationId: wager.conversationId, + creatorPhone: wager.creatorPhone, + title: wager.title, + condition: wager.condition, + sides: wager.sides, + verificationType: wager.verificationType, + verificationConfig: wager.verificationConfig, + status: wager.status, + deadline: wager.deadline, + result: wager.result, + resolvedAt: wager.resolvedAt, + proofUrl: wager.proofUrl, + createdAt: wager.createdAt, + updatedAt: wager.updatedAt, + }; +} + +function formatPosition(position: { + id: string; + wagerId: string; + phone: string; + side: string; + amount: Decimal; + matchedAmount: Decimal; + createdAt: Date; +}): WagerPosition { + return { + id: position.id, + wagerId: position.wagerId, + phone: position.phone, + side: position.side, + amount: position.amount.toNumber(), + matchedAmount: position.matchedAmount.toNumber(), + createdAt: position.createdAt, + }; +} diff --git a/src/lib/wager/index.ts b/src/lib/wager/index.ts new file mode 100644 index 0000000..e962b34 --- /dev/null +++ b/src/lib/wager/index.ts @@ -0,0 +1,16 @@ +/** + * Wager Library + * + * Exports for the wagering system including verification abstraction. + */ + +export { + type VerificationType, + type VerificationMethod, + VERIFICATION_METHODS, + getVerificationMethod, + getVerificationMethodsDescription, + validateVerificationConfig, + isReadyForResolution, + verificationConfigSchemas, +} from "./verification"; diff --git a/src/lib/wager/verification.ts b/src/lib/wager/verification.ts new file mode 100644 index 0000000..92341ff --- /dev/null +++ b/src/lib/wager/verification.ts @@ -0,0 +1,166 @@ +/** + * Wager Verification Abstraction + * + * Extensible registry pattern for verification methods. + * The agent receives descriptions to auto-select the most appropriate type. + */ + +import { z } from "zod"; + +// Must match Prisma enum +export type VerificationType = "subjective" | "deadline" | "photo_proof"; + +/** + * Configuration schemas for each verification type + */ +export const verificationConfigSchemas = { + subjective: z.object({}).optional(), + deadline: z.object({ + deadline: z.string().datetime().describe("ISO datetime when the event should be resolved"), + }), + photo_proof: z.object({ + description: z.string().optional().describe("What the photo should show"), + }).optional(), +} as const; + +/** + * Verification method definition for the registry + */ +export interface VerificationMethod { + type: VerificationType; + name: string; + description: string; + suggestedFor: string; + requiresConfig: boolean; + configSchema: z.ZodSchema; +} + +/** + * Registry of available verification methods + * Agent uses this to understand when to use each type + */ +export const VERIFICATION_METHODS: VerificationMethod[] = [ + { + type: "subjective", + name: "Subjective (Group Vote)", + description: + "Winner declared by group consensus, honor system, or concession. " + + "Anyone can call for resolution and the group decides.", + suggestedFor: + "Subjective outcomes, disputes, things requiring human judgment, " + + "debates, predictions where proof is hard to obtain", + requiresConfig: false, + configSchema: verificationConfigSchemas.subjective, + }, + { + type: "deadline", + name: "Deadline-Based", + description: + "Auto-prompts for resolution when deadline passes. " + + "Good for time-sensitive bets where the outcome will be clear at a specific time.", + suggestedFor: + "Arrival times, 'will X happen by Y time', countdowns, " + + "delivery bets, meeting deadlines, sports game outcomes", + requiresConfig: true, + configSchema: verificationConfigSchemas.deadline, + }, + { + type: "photo_proof", + name: "Photo Proof", + description: + "Requires photo evidence to verify the outcome. " + + "Winner provides timestamped photo or screenshot as proof.", + suggestedFor: + "Deliveries, physical achievements, 'prove it' scenarios, " + + "completing challenges, showing receipts or results", + requiresConfig: false, + configSchema: verificationConfigSchemas.photo_proof, + }, +]; + +/** + * Get a verification method by type + */ +export function getVerificationMethod( + type: VerificationType, +): VerificationMethod | undefined { + return VERIFICATION_METHODS.find((m) => m.type === type); +} + +/** + * Get all verification method descriptions for the agent + * This is injected into tool descriptions so the agent can make informed choices + */ +export function getVerificationMethodsDescription(): string { + return VERIFICATION_METHODS.map( + (m) => + `- ${m.type}: ${m.description} Best for: ${m.suggestedFor}`, + ).join("\n"); +} + +/** + * Validate verification config for a given type + */ +export function validateVerificationConfig( + type: VerificationType, + config: unknown, +): { valid: boolean; error?: string; config?: unknown } { + const method = getVerificationMethod(type); + if (!method) { + return { valid: false, error: `Unknown verification type: ${type}` }; + } + + if (!method.requiresConfig && !config) { + return { valid: true, config: {} }; + } + + try { + const parsed = method.configSchema.parse(config); + return { valid: true, config: parsed }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + valid: false, + error: `Invalid config for ${type}: ${error.issues.map((e) => e.message).join(", ")}`, + }; + } + return { valid: false, error: "Invalid configuration" }; + } +} + +/** + * Check if a wager is ready for resolution based on its verification type + */ +export function isReadyForResolution( + verificationType: VerificationType, + verificationConfig: unknown, + currentTime: Date = new Date(), +): { ready: boolean; reason?: string } { + switch (verificationType) { + case "subjective": + // Subjective wagers can always be resolved by group consensus + return { ready: true, reason: "Group can resolve at any time" }; + + case "deadline": { + const config = verificationConfig as { deadline?: string } | null; + if (!config?.deadline) { + return { ready: true, reason: "No deadline set" }; + } + const deadline = new Date(config.deadline); + if (currentTime >= deadline) { + return { ready: true, reason: "Deadline has passed" }; + } + return { + ready: false, + reason: `Deadline not reached (${deadline.toISOString()})`, + }; + } + + case "photo_proof": + // Photo proof wagers can be resolved once proof is provided + return { ready: true, reason: "Awaiting photo proof" }; + + default: + return { ready: true }; + } +}