diff --git a/.replit b/.replit index 16d8f80c..bc4a3398 100644 --- a/.replit +++ b/.replit @@ -47,18 +47,6 @@ externalPort = 3003 localPort = 8080 externalPort = 8080 -[[ports]] -localPort = 38557 -externalPort = 3000 - -[[ports]] -localPort = 40437 -externalPort = 3001 - -[[ports]] -localPort = 44157 -externalPort = 4200 - [deployment] deploymentTarget = "autoscale" run = ["node", "dist/server/production.mjs"] diff --git a/client/lib/ai/personas.ts b/client/lib/ai/personas.ts index 044057fb..63a20984 100644 --- a/client/lib/ai/personas.ts +++ b/client/lib/ai/personas.ts @@ -1,5 +1,6 @@ import { Type } from '@google/genai'; -import type { Persona } from './types'; +import type { Persona, UserTier, UserBadgeInfo } from './types'; +import { canAccessPersona } from './types'; import type { FunctionDeclaration } from '@google/genai'; export const AETHEX_TOOLS: FunctionDeclaration[] = [ @@ -158,7 +159,8 @@ Tone: Stern but encouraging. Focus on "shipping," not "dreaming."`, "May reject creative but complex ideas", "Tone is intentionally strict/stern" ], - requiredTier: 'Architect', + requiredTier: 'Pro', + unlockBadgeSlug: 'forge_apprentice', realm: 'gameforge' }, { @@ -198,7 +200,8 @@ Constraint: Do not hallucinate certifications (like 8(a) or HUBZone) if the user "Cannot verify official certifications (8(a), HUBZone)", "Does not guarantee contract awards" ], - requiredTier: 'Architect', + requiredTier: 'Pro', + unlockBadgeSlug: 'sbs_scholar', realm: 'corp' }, { @@ -238,7 +241,8 @@ Constraint: Keep language appropriate for a classroom setting.`, "Lesson plans are theoretical structures", "Cannot grade student work" ], - requiredTier: 'Architect', + requiredTier: 'Pro', + unlockBadgeSlug: 'curriculum_creator', realm: 'labs' }, { @@ -276,7 +280,8 @@ Tone: Concise, data-driven, executive. No fluff.`, "Analysis depends on user-provided data accuracy", "No financial liability for advice" ], - requiredTier: 'Architect', + requiredTier: 'Pro', + unlockBadgeSlug: 'data_pioneer', realm: 'corp' }, { @@ -314,6 +319,7 @@ Your Job: Output a structured "Audio Brief" for a composer: "Subjective artistic interpretation" ], requiredTier: 'Council', + unlockBadgeSlug: 'sound_designer', realm: 'gameforge' }, { @@ -352,6 +358,7 @@ Tone: Dark, mysterious, neon-soaked.`, "Restricted to Cyberpunk/Sci-Fi themes" ], requiredTier: 'Council', + unlockBadgeSlug: 'lore_master', realm: 'gameforge' }, { @@ -386,7 +393,8 @@ Your Job: "Lyrics are text-only output", "Mood is locked to Retrowave aesthetics" ], - requiredTier: 'Architect', + requiredTier: 'Pro', + unlockBadgeSlug: 'synthwave_artist', realm: 'labs' }, { @@ -423,7 +431,8 @@ Your Job: "Advice is satirical/entertainment focused", "Does not actually invest money" ], - requiredTier: 'Architect', + requiredTier: 'Pro', + unlockBadgeSlug: 'pitch_survivor', realm: 'corp' } ]; @@ -432,12 +441,16 @@ export const getPersonasByRealm = (realm: string): Persona[] => { return PERSONAS.filter(p => p.realm === realm); }; -export const getPersonasByTier = (tier: 'Free' | 'Architect' | 'Council'): Persona[] => { - const tierOrder = { 'Free': 0, 'Architect': 1, 'Council': 2 }; +export const getPersonasByTier = (tier: UserTier): Persona[] => { + const tierOrder: Record = { 'Free': 0, 'Pro': 1, 'Council': 2 }; const userTierLevel = tierOrder[tier]; return PERSONAS.filter(p => tierOrder[p.requiredTier] <= userTierLevel); }; +export const getAccessiblePersonas = (tier: UserTier, badges: UserBadgeInfo[]): Persona[] => { + return PERSONAS.filter(p => canAccessPersona(tier, p.requiredTier, badges, p.unlockBadgeSlug)); +}; + export const getDefaultPersona = (): Persona => { return PERSONAS.find(p => p.id === 'network_agent') || PERSONAS[0]; }; diff --git a/client/lib/ai/types.ts b/client/lib/ai/types.ts index 4946d393..b49e58cf 100644 --- a/client/lib/ai/types.ts +++ b/client/lib/ai/types.ts @@ -1,4 +1,5 @@ import type { FunctionDeclaration } from '@google/genai'; +import type { SubscriptionTier } from '../database.types'; export interface ChatMessage { role: 'user' | 'model'; @@ -24,7 +25,8 @@ export interface Persona { theme: PersonaTheme; capabilities: string[]; limitations: string[]; - requiredTier: 'Free' | 'Architect' | 'Council'; + requiredTier: UserTier; + unlockBadgeSlug?: string; realm?: string; } @@ -51,14 +53,75 @@ export interface ChatSession { timestamp: number; } -export type UserTier = 'Free' | 'Architect' | 'Council'; +export type UserTier = 'Free' | 'Pro' | 'Council'; export const TIER_HIERARCHY: Record = { 'Free': 0, - 'Architect': 1, + 'Pro': 1, 'Council': 2, }; -export const canAccessPersona = (userTier: UserTier, requiredTier: UserTier): boolean => { - return TIER_HIERARCHY[userTier] >= TIER_HIERARCHY[requiredTier]; +export const dbTierToUserTier = (dbTier: SubscriptionTier | null | undefined): UserTier => { + if (!dbTier) return 'Free'; + switch (dbTier) { + case 'council': return 'Council'; + case 'pro': return 'Pro'; + default: return 'Free'; + } +}; + +export const userTierToDbTier = (userTier: UserTier): SubscriptionTier => { + switch (userTier) { + case 'Council': return 'council'; + case 'Pro': return 'pro'; + default: return 'free'; + } +}; + +export interface UserBadgeInfo { + slug: string; + name: string; + earnedAt: string; +} + +export interface PersonaAccessContext { + tier: UserTier; + badges: UserBadgeInfo[]; +} + +export const canAccessPersona = ( + userTier: UserTier, + requiredTier: UserTier, + userBadges?: UserBadgeInfo[], + unlockBadgeSlug?: string +): boolean => { + if (TIER_HIERARCHY[userTier] >= TIER_HIERARCHY[requiredTier]) { + return true; + } + + if (unlockBadgeSlug && userBadges) { + return userBadges.some(badge => badge.slug === unlockBadgeSlug); + } + + return false; +}; + +export const getPersonaAccessReason = ( + userTier: UserTier, + requiredTier: UserTier, + userBadges?: UserBadgeInfo[], + unlockBadgeSlug?: string +): { hasAccess: boolean; reason: 'tier' | 'badge' | 'none'; badgeName?: string } => { + if (TIER_HIERARCHY[userTier] >= TIER_HIERARCHY[requiredTier]) { + return { hasAccess: true, reason: 'tier' }; + } + + if (unlockBadgeSlug && userBadges) { + const badge = userBadges.find(b => b.slug === unlockBadgeSlug); + if (badge) { + return { hasAccess: true, reason: 'badge', badgeName: badge.name }; + } + } + + return { hasAccess: false, reason: 'none' }; }; diff --git a/client/lib/database.types.ts b/client/lib/database.types.ts index 0bfafd99..0983c9a0 100644 --- a/client/lib/database.types.ts +++ b/client/lib/database.types.ts @@ -396,6 +396,9 @@ export type Database = { longest_streak: number | null; last_streak_at: string | null; total_xp: number | null; + tier: Database["public"]["Enums"]["subscription_tier_enum"] | null; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; twitter_url: string | null; updated_at: string; user_type: Database["public"]["Enums"]["user_type_enum"]; @@ -420,6 +423,9 @@ export type Database = { longest_streak?: number | null; last_streak_at?: string | null; total_xp?: number | null; + tier?: Database["public"]["Enums"]["subscription_tier_enum"] | null; + stripe_customer_id?: string | null; + stripe_subscription_id?: string | null; twitter_url?: string | null; updated_at?: string; user_type: Database["public"]["Enums"]["user_type_enum"]; @@ -444,6 +450,9 @@ export type Database = { longest_streak?: number | null; last_streak_at?: string | null; total_xp?: number | null; + tier?: Database["public"]["Enums"]["subscription_tier_enum"] | null; + stripe_customer_id?: string | null; + stripe_subscription_id?: string | null; twitter_url?: string | null; updated_at?: string; user_type?: Database["public"]["Enums"]["user_type_enum"]; @@ -460,6 +469,75 @@ export type Database = { }, ]; }; + badges: { + Row: { + id: string; + name: string; + slug: string; + description: string | null; + icon: string | null; + unlock_criteria: string | null; + unlocks_persona: string | null; + created_at: string; + }; + Insert: { + id?: string; + name: string; + slug: string; + description?: string | null; + icon?: string | null; + unlock_criteria?: string | null; + unlocks_persona?: string | null; + created_at?: string; + }; + Update: { + id?: string; + name?: string; + slug?: string; + description?: string | null; + icon?: string | null; + unlock_criteria?: string | null; + unlocks_persona?: string | null; + created_at?: string; + }; + Relationships: []; + }; + user_badges: { + Row: { + id: string; + user_id: string; + badge_id: string; + earned_at: string; + }; + Insert: { + id?: string; + user_id: string; + badge_id: string; + earned_at?: string; + }; + Update: { + id?: string; + user_id?: string; + badge_id?: string; + earned_at?: string; + }; + Relationships: [ + { + foreignKeyName: "user_badges_user_id_fkey"; + columns: ["user_id"]; + isOneToOne: false; + referencedRelation: "user_profiles"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "user_badges_badge_id_fkey"; + columns: ["badge_id"]; + isOneToOne: false; + referencedRelation: "badges"; + referencedColumns: ["id"]; + }, + ]; + }; }; Views: { [_ in never]: never; @@ -474,6 +552,7 @@ export type Database = { | "advanced" | "expert"; project_status_enum: "planning" | "in_progress" | "completed" | "on_hold"; + subscription_tier_enum: "free" | "pro" | "council"; user_type_enum: | "game_developer" | "client" @@ -498,3 +577,6 @@ export type UserType = Database["public"]["Enums"]["user_type_enum"]; export type ExperienceLevel = Database["public"]["Enums"]["experience_level_enum"]; export type ProjectStatus = Database["public"]["Enums"]["project_status_enum"]; +export type SubscriptionTier = Database["public"]["Enums"]["subscription_tier_enum"]; +export type Badge = Database["public"]["Tables"]["badges"]["Row"]; +export type UserBadge = Database["public"]["Tables"]["user_badges"]["Row"]; diff --git a/supabase/migrations/20241212_add_tier_badges.sql b/supabase/migrations/20241212_add_tier_badges.sql new file mode 100644 index 00000000..499a900d --- /dev/null +++ b/supabase/migrations/20241212_add_tier_badges.sql @@ -0,0 +1,79 @@ +-- Migration: Add tier and badges system for AI persona access +-- Run this migration in your Supabase SQL Editor + +-- 1. Create subscription tier enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscription_tier_enum') THEN + CREATE TYPE subscription_tier_enum AS ENUM ('free', 'pro', 'council'); + END IF; +END $$; + +-- 2. Add tier and Stripe columns to user_profiles +ALTER TABLE user_profiles +ADD COLUMN IF NOT EXISTS tier subscription_tier_enum DEFAULT 'free', +ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT, +ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT; + +-- 3. Create badges table +CREATE TABLE IF NOT EXISTS badges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT, + icon TEXT, + unlock_criteria TEXT, + unlocks_persona TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 4. Create user_badges junction table +CREATE TABLE IF NOT EXISTS user_badges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES user_profiles(id) ON DELETE CASCADE, + badge_id UUID NOT NULL REFERENCES badges(id) ON DELETE CASCADE, + earned_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, badge_id) +); + +-- 5. Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_user_badges_user_id ON user_badges(user_id); +CREATE INDEX IF NOT EXISTS idx_user_badges_badge_id ON user_badges(badge_id); +CREATE INDEX IF NOT EXISTS idx_badges_slug ON badges(slug); +CREATE INDEX IF NOT EXISTS idx_user_profiles_tier ON user_profiles(tier); +CREATE INDEX IF NOT EXISTS idx_user_profiles_stripe_customer ON user_profiles(stripe_customer_id); + +-- 6. Enable RLS on new tables +ALTER TABLE badges ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_badges ENABLE ROW LEVEL SECURITY; + +-- 7. RLS Policies for badges (read-only for authenticated users) +CREATE POLICY IF NOT EXISTS "Badges are viewable by everyone" +ON badges FOR SELECT +USING (true); + +-- 8. RLS Policies for user_badges +CREATE POLICY IF NOT EXISTS "Users can view their own badges" +ON user_badges FOR SELECT +USING (auth.uid() = user_id); + +CREATE POLICY IF NOT EXISTS "Users can view others badges" +ON user_badges FOR SELECT +USING (true); + +-- 9. Seed initial badges that unlock AI personas +INSERT INTO badges (name, slug, description, icon, unlock_criteria, unlocks_persona) VALUES + ('Forge Apprentice', 'forge_apprentice', 'Complete 3 game design reviews with Forge Master', 'hammer', 'Complete 3 game design reviews', 'forge_master'), + ('SBS Scholar', 'sbs_scholar', 'Create 5 business profiles with SBS Architect', 'building', 'Create 5 business profiles', 'sbs_architect'), + ('Curriculum Creator', 'curriculum_creator', 'Generate 10 lesson plans with Curriculum Weaver', 'book', 'Generate 10 lesson plans', 'curriculum_weaver'), + ('Data Pioneer', 'data_pioneer', 'Analyze 20 datasets with QuantumLeap', 'chart', 'Analyze 20 datasets', 'quantum_leap'), + ('Synthwave Artist', 'synthwave_artist', 'Write 15 song lyrics with Vapor', 'wave', 'Write 15 song lyrics', 'vapor'), + ('Pitch Survivor', 'pitch_survivor', 'Receive 10 critiques from Apex VC', 'money', 'Receive 10 critiques', 'apex'), + ('Sound Designer', 'sound_designer', 'Generate 25 audio briefs with Ethos Producer', 'music', 'Generate 25 audio briefs', 'ethos_producer'), + ('Lore Master', 'lore_master', 'Create 50 lore entries with AeThex Archivist', 'scroll', 'Create 50 lore entries', 'aethex_archivist') +ON CONFLICT (slug) DO NOTHING; + +-- 10. Grant permissions +GRANT SELECT ON badges TO authenticated; +GRANT SELECT ON user_badges TO authenticated; +GRANT INSERT, DELETE ON user_badges TO authenticated;