diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml
index dca2dff..ebf071d 100644
--- a/.github/workflows/docker-build-push.yml
+++ b/.github/workflows/docker-build-push.yml
@@ -45,6 +45,7 @@ jobs:
--build-arg VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }} \
--build-arg VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} \
--build-arg VITE_API_URL=${{ secrets.VITE_API_URL }} \
+ --build-arg VITE_GCP_FUNCTION_BASE_URL=https://${{ env.REGION }}-${{ env.PROJECT_ID }}.cloudfunctions.net \
-t ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE }}:${{ github.sha }} webapp/
docker tag ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE }}:${{ github.sha }} \
${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE }}:latest
diff --git a/.gitignore b/.gitignore
index 526ee7f..dafaf08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
local-dev
+dist/
+
static_assets/
scripts/
tmp/
diff --git a/_infra/cloud-run.tf b/_infra/cloud-run.tf
index bdd3b49..3e8e3e2 100644
--- a/_infra/cloud-run.tf
+++ b/_infra/cloud-run.tf
@@ -9,6 +9,12 @@ resource "google_cloud_run_service" "webapp" {
containers {
image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.repository_name}/${var.image_name}:latest"
+ # Add environment variables for the webapp
+ env {
+ name = "VITE_GCP_FUNCTION_BASE_URL"
+ value = "https://${var.region}-${var.project_id}.cloudfunctions.net"
+ }
+
resources {
limits = {
cpu = "1000m"
diff --git a/_infra/cloud_functions.tf b/_infra/cloud_functions.tf
index 44231ca..15e85d2 100644
--- a/_infra/cloud_functions.tf
+++ b/_infra/cloud_functions.tf
@@ -42,6 +42,11 @@ resource "google_service_account" "coach_file_uploader" {
project = var.project_id
}
+resource "google_service_account" "coach_avatar_generator" {
+ account_id = "coach-avatar-generator"
+ display_name = "Service Account for Coach Avatar Generator Function"
+ project = var.project_id
+}
# Create conversation storage bucket
resource "google_storage_bucket" "conversation_storage" {
@@ -134,6 +139,19 @@ resource "google_project_iam_member" "coach_file_uploader_roles" {
member = "serviceAccount:${google_service_account.coach_file_uploader.email}"
}
+resource "google_project_iam_member" "coach_avatar_generator_roles" {
+ for_each = toset([
+ "roles/cloudfunctions.invoker",
+ "roles/storage.objectUser",
+ "roles/logging.logWriter",
+ "roles/iam.serviceAccountTokenCreator"
+ ])
+
+ project = var.project_id
+ role = each.key
+ member = "serviceAccount:${google_service_account.coach_avatar_generator.email}"
+}
+
# Allow the coach file uploader service account to create tokens for itself
resource "google_service_account_iam_member" "coach_file_uploader_self_token_creator" {
service_account_id = google_service_account.coach_file_uploader.name
@@ -173,6 +191,12 @@ resource "google_storage_bucket_iam_member" "coach_file_uploader_bucket_access"
member = "serviceAccount:${google_service_account.coach_file_uploader.email}"
}
+resource "google_storage_bucket_iam_member" "coach_avatar_generator_content_bucket_access" {
+ bucket = google_storage_bucket.coach_content_bucket.name
+ role = "roles/storage.objectUser"
+ member = "serviceAccount:${google_service_account.coach_avatar_generator.email}"
+}
+
# Grant the motivational images function access to the image bucket
resource "google_storage_bucket_iam_member" "motivational_images_bucket_access" {
bucket = "${var.project_id}-image-bucket"
@@ -180,6 +204,13 @@ resource "google_storage_bucket_iam_member" "motivational_images_bucket_access"
member = "serviceAccount:${google_service_account.motivational_images.email}"
}
+# Grant the avatar generator function access to the image bucket
+resource "google_storage_bucket_iam_member" "coach_avatar_generator_image_bucket_access" {
+ bucket = "${var.project_id}-image-bucket"
+ role = "roles/storage.objectUser"
+ member = "serviceAccount:${google_service_account.coach_avatar_generator.email}"
+}
+
resource "google_project_iam_member" "motivational_images_roles" {
for_each = toset([
"roles/cloudfunctions.invoker",
@@ -272,6 +303,12 @@ data "archive_file" "coach_file_uploader_zip" {
excludes = ["node_modules"]
}
+data "archive_file" "coach_avatar_generator_zip" {
+ type = "zip"
+ source_dir = "${path.root}/../functions/coach-avatar-generator"
+ output_path = "${path.root}/tmp/coach-avatar-generator.zip"
+ excludes = ["node_modules"]
+}
# Upload the function sources to Cloud Storage
resource "google_storage_bucket_object" "motivational_images_source" {
@@ -341,6 +378,12 @@ resource "google_storage_bucket_object" "coach_file_uploader_source" {
source = data.archive_file.coach_file_uploader_zip.output_path
}
+resource "google_storage_bucket_object" "coach_avatar_generator_source" {
+ name = "coach-avatar-generator-${data.archive_file.coach_avatar_generator_zip.output_md5}.zip"
+ bucket = google_storage_bucket.function_bucket.name
+ source = data.archive_file.coach_avatar_generator_zip.output_path
+}
+
# Deploy Cloud Functions using the module
module "motivation_function" {
source = "./modules/cloud_function"
@@ -589,6 +632,29 @@ module "coach_file_uploader_function" {
]
}
+module "coach_avatar_generator_function" {
+ source = "./modules/cloud_function"
+
+ name = "coach-avatar-generator"
+ description = "Function to generate professional avatars from coach selfies"
+ region = var.region
+ bucket_name = google_storage_bucket.function_bucket.name
+ source_object = google_storage_bucket_object.coach_avatar_generator_source.name
+ entry_point = "generateCoachAvatar"
+ memory = "1Gi"
+ timeout = 540 # 9 minutes for AI image generation
+ service_account_email = google_service_account.coach_avatar_generator.email
+
+ environment_variables = {
+ PROJECT_ID = var.project_id
+ SUPABASE_URL = var.supabase_url
+ SUPABASE_SERVICE_ROLE_KEY = var.supabase_service_role_key
+ REPLICATE_API_TOKEN = var.replicate_api_key
+ ALLOWED_ORIGINS = var.allowed_origins
+ }
+ depends_on = [google_storage_bucket_object.coach_avatar_generator_source]
+}
+
resource "google_cloud_run_service_iam_member" "process_sms_invoker" {
location = module.process_sms_function.function.location
service = module.process_sms_function.function.name
@@ -651,4 +717,11 @@ resource "google_cloud_run_service_iam_member" "coach_file_uploader_invoker" {
service = module.coach_file_uploader_function.function.name
role = "roles/run.invoker"
member = "allUsers"
+}
+
+resource "google_cloud_run_service_iam_member" "coach_avatar_generator_invoker" {
+ location = module.coach_avatar_generator_function.function.location
+ service = module.coach_avatar_generator_function.function.name
+ role = "roles/run.invoker"
+ member = "allUsers"
}
\ No newline at end of file
diff --git a/_infra/outputs.tf b/_infra/outputs.tf
index fc5d98f..091d7f9 100644
--- a/_infra/outputs.tf
+++ b/_infra/outputs.tf
@@ -106,6 +106,11 @@ output "coach_file_uploader_url" {
value = module.coach_file_uploader_function.url
}
+output "coach_avatar_generator_url" {
+ description = "URL of the coach avatar generator function"
+ value = module.coach_avatar_generator_function.url
+}
+
output "conversation_bucket_name" {
description = "Name of the conversation storage bucket"
value = google_storage_bucket.conversation_storage.name
@@ -125,9 +130,6 @@ output "webapp_environment_variables" {
description = "Environment variables needed for the webapp"
value = {
VITE_GCP_FUNCTION_BASE_URL = "https://${var.region}-${var.project_id}.cloudfunctions.net"
- VITE_COACH_CONTENT_PROCESSOR_URL = module.coach_content_processor_function.url
- VITE_COACH_RESPONSE_GENERATOR_URL = module.coach_response_generator_function.url
- VITE_COACH_FILE_UPLOADER_URL = module.coach_file_uploader_function.url
}
sensitive = false
}
\ No newline at end of file
diff --git a/functions/coach-avatar-generator/avatar-generation.js b/functions/coach-avatar-generator/avatar-generation.js
new file mode 100644
index 0000000..eadbc7b
--- /dev/null
+++ b/functions/coach-avatar-generator/avatar-generation.js
@@ -0,0 +1,243 @@
+const { Storage } = require('@google-cloud/storage');
+const Replicate = require('replicate');
+
+const storage = new Storage();
+const projectId = process.env.PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT;
+const bucketName = `${projectId}-image-bucket`;
+const contentBucketName = `${projectId}-coach-content`;
+
+// Avatar styles (from motivational-images styles)
+const AVATAR_STYLES = [
+ 'Digital Art',
+ 'Comic book',
+ 'Disney Charactor', // Note: keeping original spelling from existing code
+];
+
+const AVATAR_MODELS = {
+ STYLE: {
+ id: "tencentarc/photomaker-style:467d062309da518648ba89d226490e02b8ed09b5abc15026e54e31c5a8cd0769",
+ getInput: (prompt, userPhotoUrl, style) => ({
+ prompt: prompt + " professional headshot, perfect eyes, natural skin, clean background",
+ num_steps: 50,
+ input_image: userPhotoUrl,
+ num_outputs: 1,
+ style_name: style,
+ style_strength_ratio: 35,
+ negative_prompt: "nsfw, lowres, nudity, nude, naked, bad anatomy, bad hands, bad eyes, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, casual clothes, gym clothes, workout clothes",
+ disable_safety_checker: true
+ }),
+ requiresImage: true
+ },
+ REALISTIC: {
+ id: "tencentarc/photomaker:ddfc2b08d209f9fa8c1eca692712918bd449f695dabb4a958da31802a9570fe4",
+ getInput: (prompt, userPhotoUrl) => ({
+ prompt: prompt + " professional headshot, perfect eyes, natural skin, clean background",
+ num_steps: 50,
+ input_image: userPhotoUrl,
+ num_outputs: 1,
+ negative_prompt: "nsfw, lowres, nudity, nude, naked, bad anatomy, bad hands, bad eyes, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, casual clothes, gym clothes, workout clothes",
+ disable_safety_checker: true
+ }),
+ requiresImage: true
+ }
+};
+
+const AVATAR_PROMPTS = {
+ 'Digital Art': 'professional fitness coach portrait, digital art style, confident expression, business casual attire',
+ 'Comic book': 'professional fitness coach portrait, comic book art style, heroic pose, confident expression',
+ 'Disney Charactor': 'professional fitness coach portrait, Disney animation style, friendly expression, professional attire'
+};
+
+async function saveImageToBucket(imageUrl, filename) {
+ const bucket = storage.bucket(bucketName);
+ const file = bucket.file(`generated-images/coach-avatars/${filename}`);
+
+ try {
+ const response = await fetch(imageUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
+ }
+
+ const buffer = await response.blob();
+ const writeStream = file.createWriteStream({
+ metadata: {
+ contentType: 'image/png',
+ cacheControl: 'public, max-age=3600',
+ },
+ resumable: false
+ });
+
+ return new Promise(async (resolve, reject) => {
+ writeStream.on('finish', () => {
+ resolve(`https://storage.googleapis.com/${bucketName}/generated-images/coach-avatars/${filename}`);
+ });
+
+ writeStream.on('error', (error) => {
+ console.error(`Error writing to bucket for ${filename}:`, error);
+ reject(error);
+ });
+
+ try {
+ const reader = buffer.stream().getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ writeStream.end();
+ break;
+ }
+ if (!writeStream.write(value)) {
+ await new Promise(resolve => writeStream.once('drain', resolve));
+ }
+ }
+ } catch (error) {
+ writeStream.destroy(error);
+ reject(error);
+ }
+ });
+ } catch (error) {
+ console.error('Error saving image to bucket:', error);
+ throw error;
+ }
+}
+
+async function storeSelfie(coachId, imageBuffer, mimeType) {
+ const bucket = storage.bucket(contentBucketName);
+ const extension = mimeType.includes('jpeg') ? 'jpg' : 'png';
+ const filename = `coach-content/${coachId}/selfie.${extension}`;
+ const file = bucket.file(filename);
+
+ try {
+ await file.save(imageBuffer, {
+ metadata: {
+ contentType: mimeType,
+ cacheControl: 'private, max-age=86400',
+ },
+ resumable: false
+ });
+
+ // Generate signed URL for internal use (24 hours)
+ const [signedUrl] = await file.getSignedUrl({
+ version: 'v4',
+ action: 'read',
+ expires: Date.now() + 24 * 60 * 60 * 1000 // 24 hours
+ });
+
+ return {
+ storagePath: filename,
+ signedUrl: signedUrl
+ };
+ } catch (error) {
+ console.error('Error storing selfie:', error);
+ throw error;
+ }
+}
+
+async function generateAvatar(replicate, model, prompt, userPhotoUrl, style = null) {
+ const maxRetries = 3;
+ const retryDelay = 5000; // 5 seconds
+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ console.log(`Generating avatar with prompt (attempt ${attempt}/${maxRetries}):`, prompt);
+ console.log('Using model:', model.id);
+
+ const output = await replicate.run(
+ model.id,
+ {
+ input: model.getInput(prompt, userPhotoUrl, style)
+ }
+ );
+
+ if (!output || output.length === 0) {
+ throw new Error('Model returned no output');
+ }
+
+ return output[0];
+ } catch (error) {
+ const isPaymentError = error.message?.includes('Payment Required') ||
+ error.message?.includes('spend limit');
+
+ if (isPaymentError || attempt === maxRetries) {
+ throw error;
+ }
+
+ console.warn(`Attempt ${attempt} failed:`, error.message);
+ console.log(`Retrying in ${retryDelay/1000} seconds...`);
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
+ }
+ }
+}
+
+async function generateCoachAvatars(coachId, imageBuffer, mimeType) {
+ const replicate = new Replicate({
+ auth: process.env.REPLICATE_API_TOKEN,
+ });
+
+ try {
+ // Store the original selfie
+ console.log(`Storing selfie for coach ${coachId}`);
+ const selfieResult = await storeSelfie(coachId, imageBuffer, mimeType);
+
+ // Generate avatars in each style
+ console.log(`Generating avatars for coach ${coachId} in ${AVATAR_STYLES.length} styles`);
+ const avatarResults = await Promise.allSettled(
+ AVATAR_STYLES.map(async (style) => {
+ try {
+ const prompt = AVATAR_PROMPTS[style];
+ const model = AVATAR_MODELS.STYLE;
+
+ // Generate avatar
+ const imageUrl = await generateAvatar(replicate, model, prompt, selfieResult.signedUrl, style);
+
+ // Save to bucket
+ const filename = `${coachId}-${style.toLowerCase().replace(/\s+/g, '-')}.png`;
+ const publicUrl = await saveImageToBucket(imageUrl, filename);
+
+ return {
+ style: style,
+ url: publicUrl,
+ filename: filename
+ };
+ } catch (error) {
+ console.error(`Failed to generate ${style} avatar:`, error);
+ throw error;
+ }
+ })
+ );
+
+ // Process results
+ const successfulAvatars = [];
+ const failedStyles = [];
+
+ avatarResults.forEach((result, index) => {
+ if (result.status === 'fulfilled') {
+ successfulAvatars.push(result.value);
+ } else {
+ failedStyles.push(AVATAR_STYLES[index]);
+ console.error(`Failed to generate ${AVATAR_STYLES[index]} avatar:`, result.reason);
+ }
+ });
+
+ if (successfulAvatars.length === 0) {
+ throw new Error('Failed to generate any avatars');
+ }
+
+ console.log(`Successfully generated ${successfulAvatars.length} avatars for coach ${coachId}`);
+
+ return {
+ avatars: successfulAvatars,
+ selfieUrl: selfieResult.storagePath,
+ failedStyles: failedStyles
+ };
+ } catch (error) {
+ console.error("Error in generateCoachAvatars:", error);
+ throw error;
+ }
+}
+
+module.exports = {
+ generateCoachAvatars,
+ AVATAR_STYLES,
+ storeSelfie,
+ saveImageToBucket
+};
\ No newline at end of file
diff --git a/functions/coach-avatar-generator/index.js b/functions/coach-avatar-generator/index.js
new file mode 100644
index 0000000..05b928f
--- /dev/null
+++ b/functions/coach-avatar-generator/index.js
@@ -0,0 +1,152 @@
+const { createClient } = require('@supabase/supabase-js');
+const { generateCoachAvatars } = require('./avatar-generation');
+const multer = require('multer');
+const { v4: uuidv4 } = require('uuid');
+
+// Initialize Supabase
+const supabase = createClient(
+ process.env.SUPABASE_URL,
+ process.env.SUPABASE_SERVICE_ROLE_KEY
+);
+
+// Configure multer for memory storage
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: {
+ fileSize: 10 * 1024 * 1024, // 10MB limit
+ },
+ fileFilter: (req, file, cb) => {
+ if (file.mimetype.startsWith('image/')) {
+ cb(null, true);
+ } else {
+ cb(new Error('Only image files are allowed'), false);
+ }
+ }
+});
+
+// CORS headers
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+ 'Access-Control-Max-Age': '3600'
+};
+
+/**
+ * Main function to generate coach avatars
+ */
+exports.generateCoachAvatar = async (req, res) => {
+ // Set CORS headers
+ Object.keys(corsHeaders).forEach(key => {
+ res.set(key, corsHeaders[key]);
+ });
+
+ // Handle preflight requests
+ if (req.method === 'OPTIONS') {
+ res.status(204).send('');
+ return;
+ }
+
+ // Handle file upload
+ upload.single('selfie')(req, res, async (err) => {
+ if (err) {
+ console.error('Upload error:', err);
+ return res.status(400).json({
+ error: err.message || 'File upload failed'
+ });
+ }
+
+ try {
+ const { coachId } = req.body;
+ const file = req.file;
+
+ if (!coachId) {
+ return res.status(400).json({ error: 'coachId is required' });
+ }
+
+ if (!file) {
+ return res.status(400).json({ error: 'Selfie image is required' });
+ }
+
+ console.log(`Processing avatar generation for coach ${coachId}`);
+ console.log(`File info: ${file.originalname}, ${file.mimetype}, ${file.size} bytes`);
+
+ // Generate avatars
+ const result = await generateCoachAvatars(coachId, file.buffer, file.mimetype);
+
+ res.status(200).json({
+ success: true,
+ coachId: coachId,
+ avatars: result.avatars,
+ selfieStoragePath: result.selfieUrl,
+ failedStyles: result.failedStyles,
+ message: `Generated ${result.avatars.length} avatar options`
+ });
+
+ } catch (error) {
+ console.error('Avatar generation error:', error);
+ res.status(500).json({
+ error: error.message || 'Failed to generate avatars',
+ details: process.env.NODE_ENV === 'development' ? error.stack : undefined
+ });
+ }
+ });
+};
+
+/**
+ * Function to save selected avatar to coach profile
+ */
+exports.saveSelectedAvatar = async (req, res) => {
+ // Set CORS headers
+ Object.keys(corsHeaders).forEach(key => {
+ res.set(key, corsHeaders[key]);
+ });
+
+ // Handle preflight requests
+ if (req.method === 'OPTIONS') {
+ res.status(204).send('');
+ return;
+ }
+
+ try {
+ const { coachId, selectedAvatarUrl, avatarStyle, originalSelfieUrl } = req.body;
+
+ if (!coachId || !selectedAvatarUrl || !avatarStyle) {
+ return res.status(400).json({
+ error: 'coachId, selectedAvatarUrl, and avatarStyle are required'
+ });
+ }
+
+ console.log(`Saving selected avatar for coach ${coachId}: ${avatarStyle}`);
+
+ // Update coach profile with selected avatar
+ const { data, error } = await supabase
+ .from('coach_profiles')
+ .update({
+ avatar_url: selectedAvatarUrl,
+ avatar_style: avatarStyle,
+ original_selfie_url: originalSelfieUrl,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', coachId)
+ .select()
+ .single();
+
+ if (error) {
+ console.error('Database error:', error);
+ throw error;
+ }
+
+ res.status(200).json({
+ success: true,
+ coach: data,
+ message: 'Avatar saved successfully'
+ });
+
+ } catch (error) {
+ console.error('Save avatar error:', error);
+ res.status(500).json({
+ error: error.message || 'Failed to save selected avatar'
+ });
+ }
+};
\ No newline at end of file
diff --git a/functions/coach-avatar-generator/package.json b/functions/coach-avatar-generator/package.json
new file mode 100644
index 0000000..b260c7b
--- /dev/null
+++ b/functions/coach-avatar-generator/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "coach-avatar-generator",
+ "version": "1.0.0",
+ "description": "Generates professional avatars from coach selfies using AI",
+ "main": "index.js",
+ "scripts": {
+ "start": "functions-framework --target=generateCoachAvatar",
+ "deploy": "gcloud functions deploy generateCoachAvatar --runtime nodejs18 --trigger http"
+ },
+ "dependencies": {
+ "@google-cloud/functions-framework": "4.0.0",
+ "@google-cloud/storage": "^7.0.0",
+ "@supabase/supabase-js": "^2.38.0",
+ "replicate": "1.0.1",
+ "uuid": "^9.0.0",
+ "multer": "2.0.0",
+ "sharp": "^0.32.0"
+ }
+}
\ No newline at end of file
diff --git a/supabase/migrations/20250101000001_allow_public_coach_access.sql b/supabase/migrations/20250101000001_allow_public_coach_access.sql
new file mode 100644
index 0000000..30dcd8a
--- /dev/null
+++ b/supabase/migrations/20250101000001_allow_public_coach_access.sql
@@ -0,0 +1,23 @@
+/*
+ # Allow Public Access to Public Coaches
+
+ This migration updates the RLS policies to allow unauthenticated users
+ to read public coaches from the coach_profiles table.
+
+ This is needed for the HeroCoachPage to display public custom coaches
+ to all users, not just authenticated ones.
+*/
+
+-- Drop the existing "Users can view public coaches" policy
+DROP POLICY IF EXISTS "Users can view public coaches" ON public.coach_profiles;
+
+-- Create a new policy that allows anyone (including unauthenticated users) to view public coaches
+CREATE POLICY "Anyone can view public coaches"
+ ON public.coach_profiles FOR SELECT
+ USING (public = true AND active = true);
+
+-- Also grant SELECT permission to anonymous users for public coaches
+GRANT SELECT ON public.coach_profiles TO anon;
+
+-- Add helpful comment
+COMMENT ON POLICY "Anyone can view public coaches" ON public.coach_profiles IS 'Allows unauthenticated users to view public coaches for the HeroCoachPage';
\ No newline at end of file
diff --git a/supabase/migrations/20250101000002_add_predefined_coaches.sql b/supabase/migrations/20250101000002_add_predefined_coaches.sql
new file mode 100644
index 0000000..8127ac1
--- /dev/null
+++ b/supabase/migrations/20250101000002_add_predefined_coaches.sql
@@ -0,0 +1,268 @@
+/*
+ # Add Predefined Coaches to Database
+
+ This migration adds the predefined coaches (zen_master, gym_bro, etc.)
+ as actual database entries so they can use the same LLM-powered
+ response system as custom coaches.
+*/
+
+-- This migration should run AFTER the sync migration
+-- Get the synced user profile for phone number +12533800282
+
+-- Insert Zen Master coach
+INSERT INTO public.coach_profiles (
+ id,
+ user_id,
+ user_email,
+ name,
+ handle,
+ description,
+ primary_response_style,
+ secondary_response_style,
+ emotional_response_map,
+ communication_traits,
+ voice_patterns,
+ catchphrases,
+ vocabulary_preferences,
+ content_processed,
+ total_content_pieces,
+ processing_status,
+ active,
+ max_daily_interactions,
+ public,
+ preview_sessions,
+ total_conversations,
+ created_at,
+ updated_at
+) VALUES (
+ '11111111-1111-1111-1111-111111111111',
+ (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ 'Zen Master',
+ 'zen_master',
+ 'A peaceful, mindful coach focused on holistic wellness and inner strength',
+ 'wise_mentor',
+ 'empathetic_mirror',
+ '{"calm": 9, "motivating": 7, "philosophical": 9}',
+ '{"energy_level": 4, "directness": 3, "emotion_focus": 8, "formality": 6}',
+ '{"sentence_structure": "flowing_complex", "vocabulary_level": "mindful", "pace": "slow"}',
+ ARRAY['Namaste', 'Listen to your body', 'Every step counts', 'Breathe deeply', 'Trust the process'],
+ '{"spiritual_terms": "frequent", "nature_metaphors": "common", "breathing_references": "regular"}',
+ true,
+ 0,
+ 'complete',
+ true,
+ 1000,
+ true,
+ 0,
+ 0,
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
+
+-- Insert Gym Bro coach
+INSERT INTO public.coach_profiles (
+ id,
+ user_id,
+ user_email,
+ name,
+ handle,
+ description,
+ primary_response_style,
+ secondary_response_style,
+ emotional_response_map,
+ communication_traits,
+ voice_patterns,
+ catchphrases,
+ vocabulary_preferences,
+ content_processed,
+ total_content_pieces,
+ processing_status,
+ active,
+ max_daily_interactions,
+ public,
+ preview_sessions,
+ total_conversations,
+ created_at,
+ updated_at
+) VALUES (
+ '22222222-2222-2222-2222-222222222222',
+ (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ 'Gym Bro',
+ 'gym_bro',
+ 'An enthusiastic, high-energy coach focused on gains and personal records',
+ 'cheerleader',
+ 'tough_love',
+ '{"excited": 10, "motivating": 10, "energetic": 9}',
+ '{"energy_level": 9, "directness": 7, "emotion_focus": 5, "formality": 2}',
+ '{"sentence_structure": "short_punchy", "vocabulary_level": "casual", "pace": "fast"}',
+ ARRAY['Let''s GO!', 'You got this fam!', 'GAINS!', 'Crushing it!', 'Beast mode!', 'Level UP!'],
+ '{"gym_terminology": "extensive", "caps_usage": "frequent", "emoji_usage": "heavy"}',
+ true,
+ 0,
+ 'complete',
+ true,
+ 1000,
+ true,
+ 0,
+ 0,
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
+
+-- Insert Dance Teacher coach
+INSERT INTO public.coach_profiles (
+ id,
+ user_id,
+ user_email,
+ name,
+ handle,
+ description,
+ primary_response_style,
+ secondary_response_style,
+ emotional_response_map,
+ communication_traits,
+ voice_patterns,
+ catchphrases,
+ vocabulary_preferences,
+ content_processed,
+ total_content_pieces,
+ processing_status,
+ active,
+ max_daily_interactions,
+ public,
+ preview_sessions,
+ total_conversations,
+ created_at,
+ updated_at
+) VALUES (
+ '33333333-3333-3333-3333-333333333333',
+ (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ 'Dance Teacher',
+ 'dance_teacher',
+ 'A sassy, rhythmic coach focused on movement and expression',
+ 'cheerleader',
+ 'reframe_master',
+ '{"sassy": 9, "encouraging": 8, "expressive": 10}',
+ '{"energy_level": 8, "directness": 6, "emotion_focus": 7, "formality": 3}',
+ '{"sentence_structure": "rhythmic_varied", "vocabulary_level": "trendy", "pace": "medium"}',
+ ARRAY['Work it!', 'Slay!', 'You''re serving!', 'Gorgeous!', 'Main character energy!', 'Iconic!'],
+ '{"dance_terminology": "frequent", "slang_usage": "heavy", "confidence_boosting": "constant"}',
+ true,
+ 0,
+ 'complete',
+ true,
+ 1000,
+ true,
+ 0,
+ 0,
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
+
+-- Insert Drill Sergeant coach
+INSERT INTO public.coach_profiles (
+ id,
+ user_id,
+ user_email,
+ name,
+ handle,
+ description,
+ primary_response_style,
+ secondary_response_style,
+ emotional_response_map,
+ communication_traits,
+ voice_patterns,
+ catchphrases,
+ vocabulary_preferences,
+ content_processed,
+ total_content_pieces,
+ processing_status,
+ active,
+ max_daily_interactions,
+ public,
+ preview_sessions,
+ total_conversations,
+ created_at,
+ updated_at
+) VALUES (
+ '44444444-4444-4444-4444-444444444444',
+ (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ 'Drill Sergeant',
+ 'drill_sergeant',
+ 'A disciplined, no-nonsense coach focused on structure and results',
+ 'tough_love',
+ 'data_driven',
+ '{"disciplined": 10, "commanding": 9, "results_focused": 10}',
+ '{"energy_level": 8, "directness": 10, "emotion_focus": 3, "formality": 8}',
+ '{"sentence_structure": "commanding_short", "vocabulary_level": "military", "pace": "fast"}',
+ ARRAY['No excuses!', 'Move it!', 'Mission accomplished!', 'Execute!', 'Discipline equals freedom!'],
+ '{"military_terminology": "extensive", "commands": "frequent", "results_focus": "constant"}',
+ true,
+ 0,
+ 'complete',
+ true,
+ 1000,
+ true,
+ 0,
+ 0,
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
+
+-- Insert Frat Bro coach
+INSERT INTO public.coach_profiles (
+ id,
+ user_id,
+ user_email,
+ name,
+ handle,
+ description,
+ primary_response_style,
+ secondary_response_style,
+ emotional_response_map,
+ communication_traits,
+ voice_patterns,
+ catchphrases,
+ vocabulary_preferences,
+ content_processed,
+ total_content_pieces,
+ processing_status,
+ active,
+ max_daily_interactions,
+ public,
+ preview_sessions,
+ total_conversations,
+ created_at,
+ updated_at
+) VALUES (
+ '55555555-5555-5555-5555-555555555555',
+ (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'),
+ 'Frat Bro',
+ 'frat_bro',
+ 'An over-the-top, high-energy coach focused on extreme transformation',
+ 'cheerleader',
+ 'reframe_master',
+ '{"extreme": 10, "energetic": 10, "wild": 9}',
+ '{"energy_level": 10, "directness": 8, "emotion_focus": 6, "formality": 1}',
+ '{"sentence_structure": "chaotic_caps", "vocabulary_level": "extreme_casual", "pace": "hyperfast"}',
+ ARRAY['ABSOLUTELY BONKERS!', 'BUILT DIFFERENT!', 'LEGENDARY!', 'SWOLEPOCALYPSE!', 'UNHINGED!', 'PEAK PERFORMANCE!'],
+ '{"extreme_language": "constant", "made_up_words": "frequent", "caps_usage": "excessive"}',
+ true,
+ 0,
+ 'complete',
+ true,
+ 1000,
+ true,
+ 0,
+ 0,
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
+
+-- Add helpful comments
+COMMENT ON COLUMN public.coach_profiles.id IS 'Predefined coaches use fixed UUIDs: zen_master=11111111-1111-1111-1111-111111111111, gym_bro=22222222-2222-2222-2222-222222222222, etc.';
\ No newline at end of file
diff --git a/supabase/migrations/20250101000003_sync_auth_profiles.sql b/supabase/migrations/20250101000003_sync_auth_profiles.sql
new file mode 100644
index 0000000..04b8eb1
--- /dev/null
+++ b/supabase/migrations/20250101000003_sync_auth_profiles.sql
@@ -0,0 +1,105 @@
+/*
+ # Synchronize auth.users and user_profiles
+
+ This migration ensures that auth.users and user_profiles are properly linked
+ and creates functions to keep them in sync going forward.
+*/
+
+-- First, let's see what we're working with
+-- Find mismatched records
+DO $$
+DECLARE
+ auth_id UUID;
+ profile_id UUID;
+ profile_email TEXT;
+BEGIN
+ -- Get your profile info
+ SELECT id, email INTO profile_id, profile_email
+ FROM public.user_profiles
+ WHERE phone_number = '+12533800282';
+
+ -- Get the auth user with this email
+ SELECT id INTO auth_id
+ FROM auth.users
+ WHERE email = profile_email;
+
+ RAISE NOTICE 'Profile ID: %, Auth ID: %, Email: %', profile_id, auth_id, profile_email;
+
+ -- If they don't match, fix it
+ IF auth_id IS NOT NULL AND auth_id != profile_id THEN
+ RAISE NOTICE 'Fixing ID mismatch - updating profile to use auth ID';
+
+ -- Update the user_profiles table to use the correct auth ID
+ UPDATE public.user_profiles
+ SET id = auth_id
+ WHERE phone_number = '+12533800282';
+
+ RAISE NOTICE 'Updated profile ID from % to %', profile_id, auth_id;
+ END IF;
+END $$;
+
+-- Create a function to handle auth user creation
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- When a new auth user is created, create a corresponding user profile
+ INSERT INTO public.user_profiles (id, email, created_at, updated_at)
+ VALUES (NEW.id, NEW.email, NOW(), NOW())
+ ON CONFLICT (id) DO UPDATE SET
+ email = NEW.email,
+ updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Create trigger to automatically sync new auth users
+DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
+CREATE TRIGGER on_auth_user_created
+ AFTER INSERT ON auth.users
+ FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
+
+-- Create a function to get or create a unified user
+CREATE OR REPLACE FUNCTION public.get_or_create_user(
+ user_email TEXT,
+ user_phone TEXT DEFAULT NULL,
+ user_full_name TEXT DEFAULT NULL
+)
+RETURNS UUID AS $$
+DECLARE
+ user_id UUID;
+ auth_user_id UUID;
+BEGIN
+ -- First try to find existing auth user by email
+ SELECT id INTO auth_user_id FROM auth.users WHERE email = user_email;
+
+ IF auth_user_id IS NOT NULL THEN
+ -- Auth user exists, ensure profile exists and is synced
+ INSERT INTO public.user_profiles (
+ id, email, phone_number, full_name, created_at, updated_at
+ ) VALUES (
+ auth_user_id, user_email, user_phone, user_full_name, NOW(), NOW()
+ ) ON CONFLICT (id) DO UPDATE SET
+ email = user_email,
+ phone_number = COALESCE(user_phone, user_profiles.phone_number),
+ full_name = COALESCE(user_full_name, user_profiles.full_name),
+ updated_at = NOW();
+
+ RETURN auth_user_id;
+ ELSE
+ -- No auth user exists, this shouldn't happen in normal flow
+ -- But we can handle it by creating both
+ user_id := gen_random_uuid();
+
+ INSERT INTO auth.users (id, email, email_confirmed_at, created_at, updated_at)
+ VALUES (user_id, user_email, NOW(), NOW(), NOW());
+
+ INSERT INTO public.user_profiles (
+ id, email, phone_number, full_name, created_at, updated_at
+ ) VALUES (
+ user_id, user_email, user_phone, user_full_name, NOW(), NOW()
+ );
+
+ RETURN user_id;
+ END IF;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
\ No newline at end of file
diff --git a/supabase/migrations/20250127000000_add_coach_avatar_support.sql b/supabase/migrations/20250127000000_add_coach_avatar_support.sql
new file mode 100644
index 0000000..65f199d
--- /dev/null
+++ b/supabase/migrations/20250127000000_add_coach_avatar_support.sql
@@ -0,0 +1,27 @@
+/*
+ # Add Coach Avatar Support Migration
+
+ Adds avatar functionality to the coach builder system:
+ 1. Add avatar_url column for generated professional avatar (public)
+ 2. Add original_selfie_url column for uploaded selfie (private, for regeneration)
+ 3. Add avatar_style column to track chosen style
+ 4. Add indexes for performance
+*/
+
+-- Add avatar columns to coach_profiles table
+ALTER TABLE public.coach_profiles
+ADD COLUMN avatar_url text,
+ADD COLUMN original_selfie_url text,
+ADD COLUMN avatar_style text DEFAULT 'Digital Art';
+
+-- Add helpful comments
+COMMENT ON COLUMN public.coach_profiles.avatar_url IS 'Generated avatar image URL for the coach (public, displayed in UI)';
+COMMENT ON COLUMN public.coach_profiles.original_selfie_url IS 'Original selfie uploaded by coach creator (private, for regeneration)';
+COMMENT ON COLUMN public.coach_profiles.avatar_style IS 'Style used for avatar generation (e.g., Digital Art, Comic book, Disney Character)';
+
+-- Add indexes for performance
+CREATE INDEX coach_profiles_avatar_url_idx ON public.coach_profiles(avatar_url);
+CREATE INDEX coach_profiles_avatar_style_idx ON public.coach_profiles(avatar_style);
+
+-- Grant necessary permissions for service role (cloud functions)
+GRANT UPDATE ON public.coach_profiles TO service_role;
\ No newline at end of file
diff --git a/webapp/Dockerfile b/webapp/Dockerfile
index 6e4ccee..a49eb02 100644
--- a/webapp/Dockerfile
+++ b/webapp/Dockerfile
@@ -9,12 +9,14 @@ ARG VITE_STRIPE_PUBLIC_KEY
ARG VITE_API_URL
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
+ARG VITE_GCP_FUNCTION_BASE_URL
# Set environment variables
ENV VITE_STRIPE_PUBLIC_KEY=$VITE_STRIPE_PUBLIC_KEY
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
+ENV VITE_GCP_FUNCTION_BASE_URL=$VITE_GCP_FUNCTION_BASE_URL
# Copy package files
COPY package*.json ./
diff --git a/webapp/src/App.jsx b/webapp/src/App.jsx
index 2a450eb..d5c8ae5 100644
--- a/webapp/src/App.jsx
+++ b/webapp/src/App.jsx
@@ -18,6 +18,7 @@ import { CoachBuilderProvider } from './contexts/CoachBuilderContext';
import CoachBuilderLanding from './components/CoachBuilder/CoachBuilderLanding';
import PersonalityQuestionnaire from './components/CoachBuilder/PersonalityQuestionnaire';
import ContentUpload from './components/CoachBuilder/ContentUpload';
+import AvatarUpload from './components/CoachBuilder/AvatarUpload';
import CoachPreview from './components/CoachBuilder/CoachPreview';
import CoachSavePrompt from './components/CoachBuilder/CoachSavePrompt';
import CoachDashboard from './components/MyCoaches/CoachDashboard';
@@ -507,6 +508,7 @@ export function App() {
+ Upload a selfie to generate professional avatar options for your AI coach +
++ Drop your selfie here or click to browse +
++ JPG, PNG up to 10MB +
+ + +{selectedFile.name}
++ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +
++ Generating your professional avatars... +
++ This may take 1-2 minutes +
+{generationError}
++ Don't worry! We'll use your original photo as your avatar. +
++ {coachData.avatarData.selectedAvatar.style} Style +
+{coachData.content?.length || 0} files
+ {coachData.avatarData?.selectedAvatar ? + `${coachData.avatarData.selectedAvatar.style} style` : + coachData.avatarData?.skipped ? 'Skipped' : 'Not set' + } +
+