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

@ -34,14 +34,12 @@
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-subscription-types": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-supabase": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@supabase/supabase-js": "^2.81.1",
"svelte-i18n": "^4.0.1"
}
}

View file

@ -1,5 +1,5 @@
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
import { getAuthenticatedSupabase } from '$lib/utils/supabase';
import { PUBLIC_API_URL } from '$env/static/public';
import { authService } from '$lib/auth';
// Svelte 5 runes-based deck store
@ -8,6 +8,35 @@ let currentDeck = $state<Deck | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
/**
* Helper to make authenticated API requests
*/
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const appToken = await authService.getAppToken();
if (!appToken) {
throw new Error('Not authenticated');
}
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
...options.headers
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`);
}
return response.json();
}
export const deckStore = {
get decks() {
return decks;
@ -30,31 +59,8 @@ export const deckStore = {
error = null;
try {
const appToken = await authService.getAppToken();
if (!appToken) {
throw new Error('Not authenticated');
}
const user = await authService.getUserFromToken();
if (!user) {
throw new Error('No user found');
}
const supabase = await getAuthenticatedSupabase(appToken);
const { data, error: fetchError } = await supabase
.from('decks')
.select('*, cards(count)')
.or(`user_id.eq.${user.id},and(is_public.eq.true,user_id.eq.00000000-0000-0000-0000-000000000001)`)
.order('updated_at', { ascending: false });
if (fetchError) throw fetchError;
// Map card count
decks = (data || []).map((deck: any) => ({
...deck,
card_count: deck.cards?.[0]?.count || 0
}));
const response = await apiRequest<{ decks: Deck[]; count: number }>('/v1/api/decks');
decks = response.decks || [];
} catch (err: any) {
error = err.message || 'Failed to fetch decks';
console.error('Fetch decks error:', err);
@ -71,23 +77,15 @@ export const deckStore = {
error = null;
try {
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const supabase = await getAuthenticatedSupabase(appToken);
const { data, error: fetchError } = await supabase
.from('decks')
.select('*, cards(count)')
.eq('id', id)
.single();
if (fetchError) throw fetchError;
currentDeck = {
...data,
card_count: data.cards?.[0]?.count || 0
};
// For now, find in local decks or fetch all
// TODO: Add GET /v1/api/decks/:id endpoint to backend
if (decks.length === 0) {
await this.fetchDecks();
}
currentDeck = decks.find((d) => d.id === id) || null;
if (!currentDeck) {
throw new Error('Deck not found');
}
} catch (err: any) {
error = err.message || 'Failed to fetch deck';
console.error('Fetch deck error:', err);
@ -104,35 +102,23 @@ export const deckStore = {
error = null;
try {
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const response = await apiRequest<{ success: boolean; deck: Deck }>('/v1/api/decks', {
method: 'POST',
body: JSON.stringify({
title: input.title,
description: input.description || '',
isPublic: input.is_public ?? false,
tags: input.tags || [],
settings: input.settings || {}
})
});
const user = await authService.getUserFromToken();
if (!user) throw new Error('No user found');
const supabase = await getAuthenticatedSupabase(appToken);
const newDeck = {
user_id: user.id,
title: input.title,
description: input.description || '',
is_public: input.is_public ?? false,
tags: input.tags || [],
settings: input.settings || {},
metadata: {}
};
const { data, error: createError } = await supabase
.from('decks')
.insert(newDeck)
.select()
.single();
if (createError) throw createError;
const deck = { ...data, card_count: 0 };
decks = [deck, ...decks];
return deck;
if (response.deck) {
const deck = { ...response.deck, card_count: 0 };
decks = [deck, ...decks];
return deck;
}
return null;
} catch (err: any) {
error = err.message || 'Failed to create deck';
console.error('Create deck error:', err);
@ -150,26 +136,22 @@ export const deckStore = {
error = null;
try {
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const response = await apiRequest<{ success: boolean; deck: Deck }>(
`/v1/api/decks/${id}`,
{
method: 'PUT',
body: JSON.stringify(updates)
}
);
const supabase = await getAuthenticatedSupabase(appToken);
if (response.deck) {
// Update in list
decks = decks.map((d) => (d.id === id ? { ...d, ...response.deck } : d));
const { data, error: updateError } = await supabase
.from('decks')
.update(updates)
.eq('id', id)
.select()
.single();
if (updateError) throw updateError;
// Update in list
decks = decks.map((d) => (d.id === id ? { ...d, ...data } : d));
// Update current if it's the same
if (currentDeck?.id === id) {
currentDeck = { ...currentDeck, ...data };
// Update current if it's the same
if (currentDeck?.id === id) {
currentDeck = { ...currentDeck, ...response.deck };
}
}
} catch (err: any) {
error = err.message || 'Failed to update deck';
@ -187,14 +169,9 @@ export const deckStore = {
error = null;
try {
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const supabase = await getAuthenticatedSupabase(appToken);
const { error: deleteError } = await supabase.from('decks').delete().eq('id', id);
if (deleteError) throw deleteError;
await apiRequest<{ success: boolean }>(`/v1/api/decks/${id}`, {
method: 'DELETE'
});
// Remove from list
decks = decks.filter((d) => d.id !== id);

View file

@ -1,44 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
// Create a singleton Supabase client
export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false
}
});
/**
* Get authenticated Supabase client with Mana Core token
* This injects the JWT token from Mana Core for RLS policies
*/
export async function getAuthenticatedSupabase(appToken?: string) {
if (!appToken) {
// Try to get token from sessionStorage
if (typeof window !== 'undefined') {
appToken = sessionStorage.getItem('appToken') || undefined;
}
}
if (!appToken) {
throw new Error('No auth token available');
}
// Create a new client instance with the auth token
const authenticatedClient = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${appToken}`
}
},
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false
}
});
return authenticatedClient;
}

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