feat(manadeck): complete Supabase removal from frontend and backend

- Frontend: Updated deckStore to use fetch API calls to backend
- Frontend: Removed supabase.ts utility and @supabase/supabase-js dependency
- Backend: Removed SupabaseService and supabase.service.ts
- Backend: Updated validation schema to use DATABASE_URL
- Backend: Updated health controller to remove Supabase check
- Backend: Marked AI generation endpoint as not implemented (was using Edge Functions)

Phase 4 of PostgreSQL/Drizzle migration complete.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 02:49:34 +01:00
parent 1530efa936
commit be9df4aa85
10 changed files with 87 additions and 553 deletions

View file

@ -28,7 +28,6 @@
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/terminus": "^11.0.0",
"@supabase/supabase-js": "^2.81.1",
"axios": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",

View file

@ -9,7 +9,6 @@ import { AppService } from './app.service';
import { ApiController } from './controllers/api.controller';
import { PublicController } from './controllers/public.controller';
import { HealthController } from './controllers/health.controller';
import { SupabaseService } from './services/supabase.service';
import { validationSchema } from './config/validation.schema';
import {
DatabaseModule,
@ -65,7 +64,6 @@ import {
],
providers: [
AppService,
SupabaseService,
// Database repositories
DeckRepository,
CardRepository,

View file

@ -12,10 +12,8 @@ export const validationSchema = Joi.object({
MANA_SUPABASE_SECRET_KEY: Joi.string().optional(),
SIGNUP_REDIRECT_URL: Joi.string().uri().optional(),
// Your app's database
SUPABASE_URL: Joi.string().uri().required(),
SUPABASE_ANON_KEY: Joi.string().required(),
SUPABASE_SERVICE_KEY: Joi.string().required(), // Required for edge functions
// PostgreSQL Database
DATABASE_URL: Joi.string().required(),
// JWT
JWT_SECRET: Joi.string().optional(),

View file

@ -1,9 +1,8 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Logger, BadRequestException, Req } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Logger, BadRequestException, NotImplementedException } from '@nestjs/common';
import { AuthGuard } from '@mana-core/nestjs-integration/guards';
import { CurrentUser } from '@mana-core/nestjs-integration/decorators';
import { CreditClientService } from '@mana-core/nestjs-integration';
import { CreditOperationType, getCreditCost, getOperationDescription } from '../config/credit-operations';
import { SupabaseService } from '../services/supabase.service';
import { DeckRepository, CardRepository, UserStatsRepository } from '../database';
@Controller('api')
@ -13,7 +12,6 @@ export class ApiController {
constructor(
private readonly creditClient: CreditClientService,
private readonly supabaseService: SupabaseService,
private readonly deckRepository: DeckRepository,
private readonly cardRepository: CardRepository,
private readonly userStatsRepository: UserStatsRepository,
@ -150,121 +148,15 @@ export class ApiController {
}
@Post('decks/generate')
async generateDeckWithAI(@CurrentUser() user: any, @Body() requestData: any, @Req() req: any) {
this.logger.log(`Generating AI deck for user: ${user.sub}`);
async generateDeckWithAI(@CurrentUser() user: any, @Body() requestData: any) {
this.logger.log(`AI deck generation requested by user: ${user.sub}`);
const { prompt, deckTitle, deckDescription, cardCount = 10, cardTypes, difficulty, tags } = requestData;
// Validate required fields
if (!prompt || !deckTitle) {
throw new BadRequestException({
error: 'validation_failed',
message: 'prompt and deckTitle are required',
});
}
if (cardCount < 1 || cardCount > 50) {
throw new BadRequestException({
error: 'validation_failed',
message: 'cardCount must be between 1 and 50',
});
}
const operationType = CreditOperationType.AI_DECK_GENERATION;
const creditCost = getCreditCost(operationType);
try {
// 1. Pre-flight credit validation
const validation = await this.creditClient.validateCredits(
user.sub,
operationType,
creditCost,
);
if (!validation.hasCredits) {
this.logger.warn(
`User ${user.sub} has insufficient credits for AI deck generation. Required: ${creditCost}, Available: ${validation.availableCredits}`,
);
throw new BadRequestException({
error: 'insufficient_credits',
message: `Insufficient mana. Required: ${creditCost}, Available: ${validation.availableCredits}`,
requiredCredits: creditCost,
availableCredits: validation.availableCredits,
operation: getOperationDescription(operationType),
});
}
// 2. Get the Mana token from the request
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new BadRequestException({
error: 'authentication_failed',
message: 'No authorization token found',
});
}
const manaToken = authHeader.replace('Bearer ', '');
// 3. Call the edge function via Supabase
const result = await this.supabaseService.generateDeckWithAI(
user.sub,
{
prompt,
deckTitle,
deckDescription,
cardCount,
cardTypes,
difficulty,
tags,
},
manaToken,
);
// 4. Check if the edge function was successful
if (!result.success) {
throw new BadRequestException({
error: 'deck_generation_failed',
message: result.error || 'Failed to generate deck',
});
}
// 5. Success - Consume credits
await this.creditClient.consumeCredits(
user.sub,
operationType,
creditCost,
`Generated AI deck: ${deckTitle}`,
{
deckId: result.deck?.id,
deckTitle,
cardCount: result.deck?.card_count,
prompt,
},
);
this.logger.log(`AI deck generated successfully for user ${user.sub}. ${creditCost} credits consumed.`);
return {
success: true,
userId: user.sub,
deck: result.deck,
cards: result.cards,
creditsUsed: creditCost,
message: result.message || 'Deck generated successfully with AI',
};
} catch (error) {
// If it's already a BadRequestException, rethrow it
if (error instanceof BadRequestException) {
throw error;
}
// Log other errors
this.logger.error(`Error generating AI deck for user ${user.sub}:`, error);
throw new BadRequestException({
error: 'deck_generation_failed',
message: error.message || 'Failed to generate deck with AI',
});
}
// TODO: Implement AI deck generation with a self-hosted solution
// This endpoint previously used Supabase Edge Functions which are no longer available
throw new NotImplementedException({
error: 'not_implemented',
message: 'AI deck generation is currently being migrated to a new infrastructure. Please check back later.',
});
}
@Put('decks/:id')

View file

@ -20,11 +20,10 @@ export class HealthController {
@HealthCheck()
check() {
const manaServiceUrl = this.configService.get<string>('MANA_SERVICE_URL')!;
const supabaseUrl = this.configService.get<string>('SUPABASE_URL')!;
return this.health.check([
() => this.http.pingCheck('mana-core', manaServiceUrl),
() => this.http.pingCheck('supabase', `${supabaseUrl}/rest/v1/`),
// PostgreSQL health is checked via database module initialization
]);
}

View file

@ -11,7 +11,7 @@ async function bootstrap() {
// Debug: Log environment variables before validation
logger.log('=== Environment Variables Debug ===');
logger.log(`APP_ID: ${process.env.APP_ID ? process.env.APP_ID.substring(0, 20) + '...' : 'NOT SET'}`);
logger.log(`SUPABASE_URL: ${process.env.SUPABASE_URL || 'NOT SET'}`);
logger.log(`DATABASE_URL: ${process.env.DATABASE_URL ? '[SET]' : 'NOT SET'}`);
logger.log(`MANA_SERVICE_URL: ${process.env.MANA_SERVICE_URL || 'NOT SET'}`);
logger.log('===================================');

View file

@ -1,283 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
@Injectable()
export class SupabaseService {
private readonly logger = new Logger(SupabaseService.name);
private supabase: SupabaseClient;
private supabaseServiceRole: SupabaseClient;
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL')!;
const supabaseAnonKey = this.configService.get<string>('SUPABASE_ANON_KEY')!;
const supabaseServiceKey = this.configService.get<string>('SUPABASE_SERVICE_KEY');
// Client for public operations
this.supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
});
// Client for service-level operations (optional, only if service key is provided)
if (supabaseServiceKey) {
this.supabaseServiceRole = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
});
}
this.logger.log('Supabase service initialized');
}
// Get client with user's token for RLS
getClientWithUserToken(token: string): SupabaseClient {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL')!;
const supabaseAnonKey = this.configService.get<string>('SUPABASE_ANON_KEY')!;
return createClient(supabaseUrl, supabaseAnonKey, {
global: {
headers: {
Authorization: `Bearer ${token}`,
},
},
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
});
}
// Example methods for deck operations
async getUserDecks(userId: string, token?: string) {
const client = token ? this.getClientWithUserToken(token) : this.supabase;
const { data, error } = await client
.from('decks')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) {
this.logger.error('Error fetching user decks:', error);
throw error;
}
return data;
}
async createDeck(userId: string, deckData: any, token?: string) {
const client = token ? this.getClientWithUserToken(token) : this.supabase;
const { data, error } = await client
.from('decks')
.insert({
...deckData,
user_id: userId,
created_at: new Date(),
updated_at: new Date(),
})
.select()
.single();
if (error) {
this.logger.error('Error creating deck:', error);
throw error;
}
return data;
}
async updateDeck(deckId: string, userId: string, deckData: any, token?: string) {
const client = token ? this.getClientWithUserToken(token) : this.supabase;
const { data, error } = await client
.from('decks')
.update({
...deckData,
updated_at: new Date(),
})
.eq('id', deckId)
.eq('user_id', userId) // Ensure user owns the deck
.select()
.single();
if (error) {
this.logger.error('Error updating deck:', error);
throw error;
}
return data;
}
async deleteDeck(deckId: string, userId: string, token?: string) {
const client = token ? this.getClientWithUserToken(token) : this.supabase;
const { error } = await client
.from('decks')
.delete()
.eq('id', deckId)
.eq('user_id', userId); // Ensure user owns the deck
if (error) {
this.logger.error('Error deleting deck:', error);
throw error;
}
return { success: true };
}
// Example methods for card operations
async getUserCards(userId: string, token?: string) {
const client = token ? this.getClientWithUserToken(token) : this.supabase;
const { data, error } = await client
.from('cards')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) {
this.logger.error('Error fetching user cards:', error);
throw error;
}
return data;
}
async createCard(userId: string, cardData: any, token?: string) {
const client = token ? this.getClientWithUserToken(token) : this.supabase;
const { data, error } = await client
.from('cards')
.insert({
...cardData,
user_id: userId,
created_at: new Date(),
updated_at: new Date(),
})
.select()
.single();
if (error) {
this.logger.error('Error creating card:', error);
throw error;
}
return data;
}
// Public methods (no auth required)
async getFeaturedDecks(limit = 10) {
const { data, error } = await this.supabase
.from('decks')
.select('*')
.eq('is_featured', true)
.eq('is_public', true)
.limit(limit)
.order('featured_at', { ascending: false });
if (error) {
this.logger.error('Error fetching featured decks:', error);
throw error;
}
return data;
}
async getLeaderboard(limit = 10) {
const { data, error } = await this.supabase
.from('user_stats')
.select('*')
.order('total_wins', { ascending: false })
.limit(limit);
if (error) {
this.logger.error('Error fetching leaderboard:', error);
throw error;
}
return data;
}
async getDeckTemplates(category?: string) {
let query = this.supabase
.from('deck_templates')
.select('*')
.eq('is_active', true);
if (category) {
query = query.eq('category', category);
}
const { data, error } = await query.order('popularity', { ascending: false });
if (error) {
this.logger.error('Error fetching deck templates:', error);
throw error;
}
return data;
}
// Service-level operations (using service role key)
async adminGetAllUsers() {
if (!this.supabaseServiceRole) {
throw new Error('Service role key not configured');
}
const { data, error } = await this.supabaseServiceRole.auth.admin.listUsers();
if (error) {
this.logger.error('Error fetching all users:', error);
throw error;
}
return data;
}
// Edge Function invocations
async generateDeckWithAI(
userId: string,
requestData: {
prompt: string;
deckTitle: string;
deckDescription?: string;
cardCount?: number;
cardTypes?: string[];
difficulty?: string;
tags?: string[];
},
manaToken: string,
) {
if (!this.supabaseServiceRole) {
throw new Error('Service role key not configured');
}
this.logger.log(`Invoking generate-deck edge function for user ${userId}`);
const { data, error } = await this.supabaseServiceRole.functions.invoke(
'generate-deck',
{
body: requestData,
headers: {
Authorization: `Bearer ${manaToken}`,
},
},
);
if (error) {
this.logger.error('Error invoking generate-deck edge function:', error);
throw error;
}
return data;
}
}