Update AI personas to include new subscription tiers and unlockable badges

Introduce a new 'Pro' tier for AI personas, update database schemas with tier and Stripe fields, and create a 'badges' table to manage persona access through earned badges.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 66a80a5c-6d47-429a-93e1-cf315013edf0
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/LFvmEVc
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sirpiglr 2025-12-12 23:12:45 +00:00
parent b7bac6aa0b
commit a4cf099b2c
5 changed files with 251 additions and 26 deletions

12
.replit
View file

@ -47,18 +47,6 @@ externalPort = 3003
localPort = 8080 localPort = 8080
externalPort = 8080 externalPort = 8080
[[ports]]
localPort = 38557
externalPort = 3000
[[ports]]
localPort = 40437
externalPort = 3001
[[ports]]
localPort = 44157
externalPort = 4200
[deployment] [deployment]
deploymentTarget = "autoscale" deploymentTarget = "autoscale"
run = ["node", "dist/server/production.mjs"] run = ["node", "dist/server/production.mjs"]

View file

@ -1,5 +1,6 @@
import { Type } from '@google/genai'; 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'; import type { FunctionDeclaration } from '@google/genai';
export const AETHEX_TOOLS: FunctionDeclaration[] = [ export const AETHEX_TOOLS: FunctionDeclaration[] = [
@ -158,7 +159,8 @@ Tone: Stern but encouraging. Focus on "shipping," not "dreaming."`,
"May reject creative but complex ideas", "May reject creative but complex ideas",
"Tone is intentionally strict/stern" "Tone is intentionally strict/stern"
], ],
requiredTier: 'Architect', requiredTier: 'Pro',
unlockBadgeSlug: 'forge_apprentice',
realm: 'gameforge' 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)", "Cannot verify official certifications (8(a), HUBZone)",
"Does not guarantee contract awards" "Does not guarantee contract awards"
], ],
requiredTier: 'Architect', requiredTier: 'Pro',
unlockBadgeSlug: 'sbs_scholar',
realm: 'corp' realm: 'corp'
}, },
{ {
@ -238,7 +241,8 @@ Constraint: Keep language appropriate for a classroom setting.`,
"Lesson plans are theoretical structures", "Lesson plans are theoretical structures",
"Cannot grade student work" "Cannot grade student work"
], ],
requiredTier: 'Architect', requiredTier: 'Pro',
unlockBadgeSlug: 'curriculum_creator',
realm: 'labs' realm: 'labs'
}, },
{ {
@ -276,7 +280,8 @@ Tone: Concise, data-driven, executive. No fluff.`,
"Analysis depends on user-provided data accuracy", "Analysis depends on user-provided data accuracy",
"No financial liability for advice" "No financial liability for advice"
], ],
requiredTier: 'Architect', requiredTier: 'Pro',
unlockBadgeSlug: 'data_pioneer',
realm: 'corp' realm: 'corp'
}, },
{ {
@ -314,6 +319,7 @@ Your Job: Output a structured "Audio Brief" for a composer:
"Subjective artistic interpretation" "Subjective artistic interpretation"
], ],
requiredTier: 'Council', requiredTier: 'Council',
unlockBadgeSlug: 'sound_designer',
realm: 'gameforge' realm: 'gameforge'
}, },
{ {
@ -352,6 +358,7 @@ Tone: Dark, mysterious, neon-soaked.`,
"Restricted to Cyberpunk/Sci-Fi themes" "Restricted to Cyberpunk/Sci-Fi themes"
], ],
requiredTier: 'Council', requiredTier: 'Council',
unlockBadgeSlug: 'lore_master',
realm: 'gameforge' realm: 'gameforge'
}, },
{ {
@ -386,7 +393,8 @@ Your Job:
"Lyrics are text-only output", "Lyrics are text-only output",
"Mood is locked to Retrowave aesthetics" "Mood is locked to Retrowave aesthetics"
], ],
requiredTier: 'Architect', requiredTier: 'Pro',
unlockBadgeSlug: 'synthwave_artist',
realm: 'labs' realm: 'labs'
}, },
{ {
@ -423,7 +431,8 @@ Your Job:
"Advice is satirical/entertainment focused", "Advice is satirical/entertainment focused",
"Does not actually invest money" "Does not actually invest money"
], ],
requiredTier: 'Architect', requiredTier: 'Pro',
unlockBadgeSlug: 'pitch_survivor',
realm: 'corp' realm: 'corp'
} }
]; ];
@ -432,12 +441,16 @@ export const getPersonasByRealm = (realm: string): Persona[] => {
return PERSONAS.filter(p => p.realm === realm); return PERSONAS.filter(p => p.realm === realm);
}; };
export const getPersonasByTier = (tier: 'Free' | 'Architect' | 'Council'): Persona[] => { export const getPersonasByTier = (tier: UserTier): Persona[] => {
const tierOrder = { 'Free': 0, 'Architect': 1, 'Council': 2 }; const tierOrder: Record<UserTier, number> = { 'Free': 0, 'Pro': 1, 'Council': 2 };
const userTierLevel = tierOrder[tier]; const userTierLevel = tierOrder[tier];
return PERSONAS.filter(p => tierOrder[p.requiredTier] <= userTierLevel); 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 => { export const getDefaultPersona = (): Persona => {
return PERSONAS.find(p => p.id === 'network_agent') || PERSONAS[0]; return PERSONAS.find(p => p.id === 'network_agent') || PERSONAS[0];
}; };

View file

@ -1,4 +1,5 @@
import type { FunctionDeclaration } from '@google/genai'; import type { FunctionDeclaration } from '@google/genai';
import type { SubscriptionTier } from '../database.types';
export interface ChatMessage { export interface ChatMessage {
role: 'user' | 'model'; role: 'user' | 'model';
@ -24,7 +25,8 @@ export interface Persona {
theme: PersonaTheme; theme: PersonaTheme;
capabilities: string[]; capabilities: string[];
limitations: string[]; limitations: string[];
requiredTier: 'Free' | 'Architect' | 'Council'; requiredTier: UserTier;
unlockBadgeSlug?: string;
realm?: string; realm?: string;
} }
@ -51,14 +53,75 @@ export interface ChatSession {
timestamp: number; timestamp: number;
} }
export type UserTier = 'Free' | 'Architect' | 'Council'; export type UserTier = 'Free' | 'Pro' | 'Council';
export const TIER_HIERARCHY: Record<UserTier, number> = { export const TIER_HIERARCHY: Record<UserTier, number> = {
'Free': 0, 'Free': 0,
'Architect': 1, 'Pro': 1,
'Council': 2, 'Council': 2,
}; };
export const canAccessPersona = (userTier: UserTier, requiredTier: UserTier): boolean => { export const dbTierToUserTier = (dbTier: SubscriptionTier | null | undefined): UserTier => {
return TIER_HIERARCHY[userTier] >= TIER_HIERARCHY[requiredTier]; 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' };
}; };

View file

@ -396,6 +396,9 @@ export type Database = {
longest_streak: number | null; longest_streak: number | null;
last_streak_at: string | null; last_streak_at: string | null;
total_xp: number | 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; twitter_url: string | null;
updated_at: string; updated_at: string;
user_type: Database["public"]["Enums"]["user_type_enum"]; user_type: Database["public"]["Enums"]["user_type_enum"];
@ -420,6 +423,9 @@ export type Database = {
longest_streak?: number | null; longest_streak?: number | null;
last_streak_at?: string | null; last_streak_at?: string | null;
total_xp?: number | 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; twitter_url?: string | null;
updated_at?: string; updated_at?: string;
user_type: Database["public"]["Enums"]["user_type_enum"]; user_type: Database["public"]["Enums"]["user_type_enum"];
@ -444,6 +450,9 @@ export type Database = {
longest_streak?: number | null; longest_streak?: number | null;
last_streak_at?: string | null; last_streak_at?: string | null;
total_xp?: number | 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; twitter_url?: string | null;
updated_at?: string; updated_at?: string;
user_type?: Database["public"]["Enums"]["user_type_enum"]; 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: { Views: {
[_ in never]: never; [_ in never]: never;
@ -474,6 +552,7 @@ export type Database = {
| "advanced" | "advanced"
| "expert"; | "expert";
project_status_enum: "planning" | "in_progress" | "completed" | "on_hold"; project_status_enum: "planning" | "in_progress" | "completed" | "on_hold";
subscription_tier_enum: "free" | "pro" | "council";
user_type_enum: user_type_enum:
| "game_developer" | "game_developer"
| "client" | "client"
@ -498,3 +577,6 @@ export type UserType = Database["public"]["Enums"]["user_type_enum"];
export type ExperienceLevel = export type ExperienceLevel =
Database["public"]["Enums"]["experience_level_enum"]; Database["public"]["Enums"]["experience_level_enum"];
export type ProjectStatus = Database["public"]["Enums"]["project_status_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"];

View file

@ -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;