mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
feat(manadeck): migrate backend from Supabase to PostgreSQL with Drizzle ORM
Phase 3 of ManaDeck database migration: Database Package (@manacore/manadeck-database): - Configure package for NodeNext module resolution with .js extensions - Add build script and proper exports for ESM/CJS compatibility - Export schema types and Drizzle utilities Backend Migration: - Add DatabaseModule with singleton database provider - Create repository layer with Drizzle ORM: - DeckRepository: CRUD operations for decks - CardRepository: CRUD operations for cards - UserStatsRepository: Stats and leaderboard queries - DeckTemplateRepository: Template management - Update ApiController to use repositories for all database operations - Update PublicController to use repositories for featured decks, leaderboard, templates - Add DATABASE_URL environment variable support The backend now uses PostgreSQL via Drizzle ORM instead of Supabase SDK for database operations. Supabase is still used for auth (via Mana Core) and edge functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
84f9343d25
commit
1530efa936
26 changed files with 3448 additions and 253 deletions
|
|
@ -20,6 +20,7 @@
|
|||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/manadeck-database": "workspace:*",
|
||||
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ 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,
|
||||
DeckRepository,
|
||||
CardRepository,
|
||||
UserStatsRepository,
|
||||
DeckTemplateRepository,
|
||||
} from './database';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -46,6 +53,9 @@ import { validationSchema } from './config/validation.schema';
|
|||
// Health checks
|
||||
TerminusModule,
|
||||
HttpModule,
|
||||
|
||||
// Database (Drizzle/PostgreSQL)
|
||||
DatabaseModule,
|
||||
],
|
||||
controllers: [
|
||||
AppController,
|
||||
|
|
@ -53,7 +63,15 @@ import { validationSchema } from './config/validation.schema';
|
|||
PublicController,
|
||||
HealthController,
|
||||
],
|
||||
providers: [AppService, SupabaseService],
|
||||
providers: [
|
||||
AppService,
|
||||
SupabaseService,
|
||||
// Database repositories
|
||||
DeckRepository,
|
||||
CardRepository,
|
||||
UserStatsRepository,
|
||||
DeckTemplateRepository,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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')
|
||||
@UseGuards(AuthGuard)
|
||||
|
|
@ -13,6 +14,9 @@ export class ApiController {
|
|||
constructor(
|
||||
private readonly creditClient: CreditClientService,
|
||||
private readonly supabaseService: SupabaseService,
|
||||
private readonly deckRepository: DeckRepository,
|
||||
private readonly cardRepository: CardRepository,
|
||||
private readonly userStatsRepository: UserStatsRepository,
|
||||
) {}
|
||||
|
||||
@Get('profile')
|
||||
|
|
@ -58,12 +62,13 @@ export class ApiController {
|
|||
}
|
||||
|
||||
@Get('decks')
|
||||
getUserDecks(@CurrentUser() user: any) {
|
||||
// This would fetch from Supabase in a real implementation
|
||||
async getUserDecks(@CurrentUser() user: any) {
|
||||
this.logger.log(`Getting decks for user: ${user.sub}`);
|
||||
const decks = await this.deckRepository.findByUserId(user.sub);
|
||||
return {
|
||||
userId: user.sub,
|
||||
decks: [],
|
||||
message: 'Fetch user decks from Supabase',
|
||||
decks,
|
||||
count: decks.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -96,24 +101,27 @@ export class ApiController {
|
|||
});
|
||||
}
|
||||
|
||||
// 2. Perform the operation (create deck in Supabase)
|
||||
// TODO: Implement actual deck creation logic with Supabase
|
||||
const newDeck = {
|
||||
id: `deck_${Date.now()}`,
|
||||
...deckData,
|
||||
// 2. Perform the operation (create deck in PostgreSQL)
|
||||
const newDeck = await this.deckRepository.create({
|
||||
userId: user.sub,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
title: deckData.name || deckData.title || 'Untitled Deck',
|
||||
description: deckData.description,
|
||||
coverImageUrl: deckData.coverImageUrl,
|
||||
isPublic: deckData.isPublic ?? false,
|
||||
settings: deckData.settings || {},
|
||||
tags: deckData.tags || [],
|
||||
metadata: deckData.metadata || {},
|
||||
});
|
||||
|
||||
// 3. Success - Consume credits
|
||||
await this.creditClient.consumeCredits(
|
||||
user.sub,
|
||||
operationType,
|
||||
creditCost,
|
||||
`Created deck: ${deckData.name || 'Unnamed Deck'}`,
|
||||
`Created deck: ${newDeck.title}`,
|
||||
{
|
||||
deckId: newDeck.id,
|
||||
deckName: deckData.name,
|
||||
deckName: newDeck.title,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -260,59 +268,115 @@ export class ApiController {
|
|||
}
|
||||
|
||||
@Put('decks/:id')
|
||||
updateDeck(
|
||||
async updateDeck(
|
||||
@CurrentUser() user: any,
|
||||
@Param('id') deckId: string,
|
||||
@Body() deckData: any,
|
||||
) {
|
||||
this.logger.log(`Updating deck ${deckId} for user: ${user.sub}`);
|
||||
|
||||
const updatedDeck = await this.deckRepository.update(deckId, user.sub, {
|
||||
title: deckData.title,
|
||||
description: deckData.description,
|
||||
coverImageUrl: deckData.coverImageUrl,
|
||||
isPublic: deckData.isPublic,
|
||||
settings: deckData.settings,
|
||||
tags: deckData.tags,
|
||||
metadata: deckData.metadata,
|
||||
});
|
||||
|
||||
if (!updatedDeck) {
|
||||
throw new BadRequestException({
|
||||
error: 'deck_not_found',
|
||||
message: 'Deck not found or you do not have permission to update it',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: user.sub,
|
||||
deckId,
|
||||
deck: deckData,
|
||||
message: 'Deck would be updated in Supabase',
|
||||
deck: updatedDeck,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete('decks/:id')
|
||||
deleteDeck(@CurrentUser() user: any, @Param('id') deckId: string) {
|
||||
async deleteDeck(@CurrentUser() user: any, @Param('id') deckId: string) {
|
||||
this.logger.log(`Deleting deck ${deckId} for user: ${user.sub}`);
|
||||
|
||||
const deleted = await this.deckRepository.delete(deckId, user.sub);
|
||||
|
||||
if (!deleted) {
|
||||
throw new BadRequestException({
|
||||
error: 'deck_not_found',
|
||||
message: 'Deck not found or you do not have permission to delete it',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: user.sub,
|
||||
deckId,
|
||||
message: 'Deck would be deleted from Supabase',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('cards')
|
||||
getUserCards(@CurrentUser() user: any) {
|
||||
async getUserCards(@CurrentUser() user: any) {
|
||||
this.logger.log(`Getting cards for user: ${user.sub}`);
|
||||
const cards = await this.cardRepository.findByUserDecks(user.sub);
|
||||
return {
|
||||
userId: user.sub,
|
||||
cards: [],
|
||||
message: 'Fetch user cards from Supabase',
|
||||
cards,
|
||||
count: cards.length,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('cards')
|
||||
createCard(@CurrentUser() user: any, @Body() cardData: any) {
|
||||
async createCard(@CurrentUser() user: any, @Body() cardData: any) {
|
||||
this.logger.log(`Creating card for user: ${user.sub}`);
|
||||
|
||||
// Verify the deck belongs to the user
|
||||
const deck = await this.deckRepository.findByIdAndUserId(cardData.deckId, user.sub);
|
||||
if (!deck) {
|
||||
throw new BadRequestException({
|
||||
error: 'deck_not_found',
|
||||
message: 'Deck not found or you do not have permission to add cards to it',
|
||||
});
|
||||
}
|
||||
|
||||
const card = await this.cardRepository.create({
|
||||
deckId: cardData.deckId,
|
||||
title: cardData.title,
|
||||
content: cardData.content,
|
||||
cardType: cardData.cardType || 'flashcard',
|
||||
position: cardData.position ?? 0,
|
||||
aiModel: cardData.aiModel,
|
||||
aiPrompt: cardData.aiPrompt,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: user.sub,
|
||||
card: cardData,
|
||||
message: 'Card would be created in Supabase',
|
||||
card,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
getUserStats(@CurrentUser() user: any) {
|
||||
async getUserStats(@CurrentUser() user: any) {
|
||||
this.logger.log(`Getting stats for user: ${user.sub}`);
|
||||
|
||||
const [stats, totalDecks, totalCards] = await Promise.all([
|
||||
this.userStatsRepository.findOrCreate(user.sub),
|
||||
this.deckRepository.countByUserId(user.sub),
|
||||
this.cardRepository.countByUserId(user.sub),
|
||||
]);
|
||||
|
||||
return {
|
||||
userId: user.sub,
|
||||
stats: {
|
||||
totalDecks: 0,
|
||||
totalCards: 0,
|
||||
lastActive: new Date(),
|
||||
...stats,
|
||||
totalDecks,
|
||||
totalCards,
|
||||
},
|
||||
message: 'Fetch user stats from Supabase',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,18 @@
|
|||
import { Controller, Get, UseGuards, Query, Logger } from '@nestjs/common';
|
||||
import { OptionalAuthGuard } from '@mana-core/nestjs-integration/guards';
|
||||
import { CurrentUser, Public } from '@mana-core/nestjs-integration/decorators';
|
||||
import { DeckRepository, UserStatsRepository, DeckTemplateRepository } from '../database';
|
||||
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
private readonly logger = new Logger(PublicController.name);
|
||||
|
||||
constructor(
|
||||
private readonly deckRepository: DeckRepository,
|
||||
private readonly userStatsRepository: UserStatsRepository,
|
||||
private readonly deckTemplateRepository: DeckTemplateRepository,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get('health')
|
||||
health() {
|
||||
|
|
@ -24,56 +31,61 @@ export class PublicController {
|
|||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('featured-decks')
|
||||
getFeaturedDecks(@CurrentUser() user?: any) {
|
||||
// User might be authenticated or null
|
||||
async getFeaturedDecks(@CurrentUser() user?: any) {
|
||||
const decks = await this.deckRepository.findFeatured(10);
|
||||
|
||||
if (user) {
|
||||
this.logger.log(`Getting personalized featured decks for user: ${user.sub}`);
|
||||
return {
|
||||
type: 'personalized',
|
||||
userId: user.sub,
|
||||
decks: [],
|
||||
message: 'Personalized featured decks based on user preferences',
|
||||
decks,
|
||||
count: decks.length,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log('Getting generic featured decks');
|
||||
return {
|
||||
type: 'generic',
|
||||
decks: [],
|
||||
message: 'Generic featured decks for anonymous users',
|
||||
decks,
|
||||
count: decks.length,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('leaderboard')
|
||||
getLeaderboard(@CurrentUser() user?: any, @Query('limit') limit = '10') {
|
||||
const limitNum = parseInt(limit, 10);
|
||||
async getLeaderboard(@CurrentUser() user?: any, @Query('limit') limit = '10') {
|
||||
const limitNum = Math.min(parseInt(limit, 10) || 10, 100);
|
||||
const leaderboard = await this.userStatsRepository.getLeaderboard(limitNum);
|
||||
|
||||
if (user) {
|
||||
this.logger.log(`Getting leaderboard with user ${user.sub} position`);
|
||||
const userPosition = await this.userStatsRepository.getUserPosition(user.sub);
|
||||
return {
|
||||
leaderboard: [],
|
||||
userPosition: null,
|
||||
leaderboard,
|
||||
userPosition,
|
||||
userId: user.sub,
|
||||
limit: limitNum,
|
||||
message: 'Leaderboard with user position highlighted',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
leaderboard: [],
|
||||
leaderboard,
|
||||
limit: limitNum,
|
||||
message: 'Public leaderboard',
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('deck-templates')
|
||||
getDeckTemplates(@Query('category') category?: string) {
|
||||
async getDeckTemplates(@Query('category') category?: string) {
|
||||
const templates = category
|
||||
? await this.deckTemplateRepository.findByCategory(category)
|
||||
: await this.deckTemplateRepository.findPublic();
|
||||
|
||||
return {
|
||||
category: category || 'all',
|
||||
templates: [],
|
||||
message: 'Public deck templates available for all users',
|
||||
templates,
|
||||
count: templates.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
29
manadeck/backend/src/database/database.module.ts
Normal file
29
manadeck/backend/src/database/database.module.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Module, Global, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { getDb, closeDb, type Database } from '@manacore/manadeck-database/client';
|
||||
|
||||
export const DATABASE_TOKEN = 'DATABASE';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: () => {
|
||||
const logger = new Logger('DatabaseModule');
|
||||
logger.log('Initializing database connection');
|
||||
return getDb();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Closing database connection');
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
||||
export type { Database };
|
||||
2
manadeck/backend/src/database/index.ts
Normal file
2
manadeck/backend/src/database/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { DatabaseModule, DATABASE_TOKEN, type Database } from './database.module';
|
||||
export * from './repositories';
|
||||
125
manadeck/backend/src/database/repositories/card.repository.ts
Normal file
125
manadeck/backend/src/database/repositories/card.repository.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
cards,
|
||||
decks,
|
||||
type Card,
|
||||
type NewCard,
|
||||
eq,
|
||||
and,
|
||||
asc,
|
||||
sql,
|
||||
} from '@manacore/manadeck-database';
|
||||
|
||||
@Injectable()
|
||||
export class CardRepository {
|
||||
private readonly logger = new Logger(CardRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findByDeckId(deckId: string): Promise<Card[]> {
|
||||
this.logger.debug(`Finding cards for deck: ${deckId}`);
|
||||
return this.db
|
||||
.select()
|
||||
.from(cards)
|
||||
.where(eq(cards.deckId, deckId))
|
||||
.orderBy(asc(cards.position));
|
||||
}
|
||||
|
||||
async findByDeckIdAndUserId(deckId: string, userId: string): Promise<Card[]> {
|
||||
// Join with decks to verify ownership
|
||||
const result = await this.db
|
||||
.select({
|
||||
card: cards,
|
||||
})
|
||||
.from(cards)
|
||||
.innerJoin(decks, eq(cards.deckId, decks.id))
|
||||
.where(and(eq(cards.deckId, deckId), eq(decks.userId, userId)))
|
||||
.orderBy(asc(cards.position));
|
||||
return result.map((r) => r.card);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Card | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(cards)
|
||||
.where(eq(cards.id, id))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByUserDecks(userId: string): Promise<Card[]> {
|
||||
// Get all cards from decks owned by the user
|
||||
const result = await this.db
|
||||
.select({
|
||||
card: cards,
|
||||
})
|
||||
.from(cards)
|
||||
.innerJoin(decks, eq(cards.deckId, decks.id))
|
||||
.where(eq(decks.userId, userId))
|
||||
.orderBy(asc(cards.deckId), asc(cards.position));
|
||||
return result.map((r) => r.card);
|
||||
}
|
||||
|
||||
async create(data: NewCard): Promise<Card> {
|
||||
this.logger.debug(`Creating card in deck: ${data.deckId}`);
|
||||
const result = await this.db.insert(cards).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async createMany(data: NewCard[]): Promise<Card[]> {
|
||||
if (data.length === 0) return [];
|
||||
this.logger.debug(`Creating ${data.length} cards`);
|
||||
return this.db.insert(cards).values(data).returning();
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<NewCard, 'id' | 'deckId' | 'createdAt'>>
|
||||
): Promise<Card | null> {
|
||||
this.logger.debug(`Updating card: ${id}`);
|
||||
const result = await this.db
|
||||
.update(cards)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cards.id, id))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
this.logger.debug(`Deleting card: ${id}`);
|
||||
const result = await this.db
|
||||
.delete(cards)
|
||||
.where(eq(cards.id, id))
|
||||
.returning({ id: cards.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async deleteByDeckId(deckId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.delete(cards)
|
||||
.where(eq(cards.deckId, deckId))
|
||||
.returning({ id: cards.id });
|
||||
return result.length;
|
||||
}
|
||||
|
||||
async countByDeckId(deckId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(cards)
|
||||
.where(eq(cards.deckId, deckId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(cards)
|
||||
.innerJoin(decks, eq(cards.deckId, decks.id))
|
||||
.where(eq(decks.userId, userId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
deckTemplates,
|
||||
type DeckTemplate,
|
||||
type NewDeckTemplate,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
sql,
|
||||
} from '@manacore/manadeck-database';
|
||||
|
||||
@Injectable()
|
||||
export class DeckTemplateRepository {
|
||||
private readonly logger = new Logger(DeckTemplateRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findAll(includeInactive = false): Promise<DeckTemplate[]> {
|
||||
if (includeInactive) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(deckTemplates)
|
||||
.orderBy(desc(deckTemplates.popularity));
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(deckTemplates)
|
||||
.where(eq(deckTemplates.isActive, true))
|
||||
.orderBy(desc(deckTemplates.popularity));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<DeckTemplate | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(deckTemplates)
|
||||
.where(eq(deckTemplates.id, id))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByCategory(category: string): Promise<DeckTemplate[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(deckTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(deckTemplates.category, category),
|
||||
eq(deckTemplates.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(deckTemplates.popularity));
|
||||
}
|
||||
|
||||
async findPublic(): Promise<DeckTemplate[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(deckTemplates)
|
||||
.where(
|
||||
and(eq(deckTemplates.isPublic, true), eq(deckTemplates.isActive, true))
|
||||
)
|
||||
.orderBy(desc(deckTemplates.popularity));
|
||||
}
|
||||
|
||||
async create(data: NewDeckTemplate): Promise<DeckTemplate> {
|
||||
this.logger.debug(`Creating deck template: ${data.title}`);
|
||||
const result = await this.db
|
||||
.insert(deckTemplates)
|
||||
.values(data)
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<NewDeckTemplate, 'id' | 'createdAt'>>
|
||||
): Promise<DeckTemplate | null> {
|
||||
this.logger.debug(`Updating deck template: ${id}`);
|
||||
const result = await this.db
|
||||
.update(deckTemplates)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(deckTemplates.id, id))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
this.logger.debug(`Deleting deck template: ${id}`);
|
||||
const result = await this.db
|
||||
.delete(deckTemplates)
|
||||
.where(eq(deckTemplates.id, id))
|
||||
.returning({ id: deckTemplates.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async incrementPopularity(id: string): Promise<DeckTemplate | null> {
|
||||
const result = await this.db
|
||||
.update(deckTemplates)
|
||||
.set({
|
||||
popularity: sql`${deckTemplates.popularity} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(deckTemplates.id, id))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async getCategories(): Promise<string[]> {
|
||||
const result = await this.db
|
||||
.selectDistinct({ category: deckTemplates.category })
|
||||
.from(deckTemplates)
|
||||
.where(eq(deckTemplates.isActive, true));
|
||||
return result
|
||||
.map((r) => r.category)
|
||||
.filter((c): c is string => c !== null);
|
||||
}
|
||||
}
|
||||
103
manadeck/backend/src/database/repositories/deck.repository.ts
Normal file
103
manadeck/backend/src/database/repositories/deck.repository.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
decks,
|
||||
type Deck,
|
||||
type NewDeck,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
sql,
|
||||
} from '@manacore/manadeck-database';
|
||||
|
||||
@Injectable()
|
||||
export class DeckRepository {
|
||||
private readonly logger = new Logger(DeckRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<Deck[]> {
|
||||
this.logger.debug(`Finding decks for user: ${userId}`);
|
||||
return this.db
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(eq(decks.userId, userId))
|
||||
.orderBy(desc(decks.createdAt));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Deck | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(eq(decks.id, id))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdAndUserId(id: string, userId: string): Promise<Deck | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async create(data: NewDeck): Promise<Deck> {
|
||||
this.logger.debug(`Creating deck: ${data.title}`);
|
||||
const result = await this.db.insert(decks).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<Omit<NewDeck, 'id' | 'userId' | 'createdAt'>>
|
||||
): Promise<Deck | null> {
|
||||
this.logger.debug(`Updating deck: ${id}`);
|
||||
const result = await this.db
|
||||
.update(decks)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<boolean> {
|
||||
this.logger.debug(`Deleting deck: ${id}`);
|
||||
const result = await this.db
|
||||
.delete(decks)
|
||||
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
||||
.returning({ id: decks.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async findFeatured(limit = 10): Promise<Deck[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(and(eq(decks.isFeatured, true), eq(decks.isPublic, true)))
|
||||
.orderBy(desc(decks.featuredAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async findPublic(limit = 10): Promise<Deck[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(eq(decks.isPublic, true))
|
||||
.orderBy(desc(decks.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(decks)
|
||||
.where(eq(decks.userId, userId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
4
manadeck/backend/src/database/repositories/index.ts
Normal file
4
manadeck/backend/src/database/repositories/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { DeckRepository } from './deck.repository';
|
||||
export { CardRepository } from './card.repository';
|
||||
export { UserStatsRepository } from './user-stats.repository';
|
||||
export { DeckTemplateRepository } from './deck-template.repository';
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
userStats,
|
||||
type UserStats,
|
||||
type NewUserStats,
|
||||
eq,
|
||||
desc,
|
||||
sql,
|
||||
} from '@manacore/manadeck-database';
|
||||
|
||||
@Injectable()
|
||||
export class UserStatsRepository {
|
||||
private readonly logger = new Logger(UserStatsRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserStats | null> {
|
||||
this.logger.debug(`Finding stats for user: ${userId}`);
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findOrCreate(userId: string): Promise<UserStats> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
if (existing) return existing;
|
||||
|
||||
this.logger.debug(`Creating stats for user: ${userId}`);
|
||||
const result = await this.db
|
||||
.insert(userStats)
|
||||
.values({ userId })
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
data: Partial<Omit<NewUserStats, 'userId' | 'createdAt'>>
|
||||
): Promise<UserStats | null> {
|
||||
this.logger.debug(`Updating stats for user: ${userId}`);
|
||||
const result = await this.db
|
||||
.update(userStats)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async getLeaderboard(limit = 10): Promise<UserStats[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.orderBy(desc(userStats.totalWins))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async getLeaderboardByStreak(limit = 10): Promise<UserStats[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.orderBy(desc(userStats.streakDays))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async getUserPosition(userId: string): Promise<number | null> {
|
||||
// Get user's total wins
|
||||
const user = await this.findByUserId(userId);
|
||||
if (!user) return null;
|
||||
|
||||
// Count users with higher wins
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(userStats)
|
||||
.where(sql`${userStats.totalWins} > ${user.totalWins}`);
|
||||
|
||||
return (result[0]?.count || 0) + 1;
|
||||
}
|
||||
|
||||
async incrementWins(userId: string, count = 1): Promise<UserStats | null> {
|
||||
const result = await this.db
|
||||
.update(userStats)
|
||||
.set({
|
||||
totalWins: sql`${userStats.totalWins} + ${count}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async incrementSessions(userId: string): Promise<UserStats | null> {
|
||||
const result = await this.db
|
||||
.update(userStats)
|
||||
.set({
|
||||
totalSessions: sql`${userStats.totalSessions} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async updateStudyProgress(
|
||||
userId: string,
|
||||
cardsStudied: number,
|
||||
timeSeconds: number,
|
||||
accuracy: number
|
||||
): Promise<UserStats | null> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (!existing) {
|
||||
await this.findOrCreate(userId);
|
||||
}
|
||||
|
||||
// Calculate new average accuracy
|
||||
const currentAvg = existing ? parseFloat(existing.averageAccuracy) : 0;
|
||||
const currentTotal = existing?.totalCardsStudied || 0;
|
||||
const newTotal = currentTotal + cardsStudied;
|
||||
const newAvg =
|
||||
newTotal > 0
|
||||
? (currentAvg * currentTotal + accuracy * cardsStudied) / newTotal
|
||||
: accuracy;
|
||||
|
||||
const result = await this.db
|
||||
.update(userStats)
|
||||
.set({
|
||||
totalCardsStudied: sql`${userStats.totalCardsStudied} + ${cardsStudied}`,
|
||||
totalTimeSeconds: sql`${userStats.totalTimeSeconds} + ${timeSeconds}`,
|
||||
averageAccuracy: newAvg.toFixed(2),
|
||||
lastStudyDate: today,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,32 @@
|
|||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema/index.ts",
|
||||
"./client": "./src/client.ts"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema/index.d.ts",
|
||||
"import": "./dist/schema/index.js",
|
||||
"require": "./dist/schema/index.js",
|
||||
"default": "./dist/schema/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client.d.ts",
|
||||
"import": "./dist/client.js",
|
||||
"require": "./dist/client.js",
|
||||
"default": "./dist/client.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"prepare": "pnpm build",
|
||||
"docker:up": "docker compose up -d",
|
||||
"docker:down": "docker compose down",
|
||||
"docker:logs": "docker compose logs -f postgres",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
import * as schema from './schema/index.js';
|
||||
|
||||
// Singleton instance for the database client
|
||||
let dbInstance: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Main entry point for @manacore/manadeck-database
|
||||
|
||||
// Export database client utilities
|
||||
export { createClient, getDb, closeDb, type Database } from './client';
|
||||
export { createClient, getDb, closeDb, type Database } from './client.js';
|
||||
|
||||
// Export Drizzle utilities
|
||||
export {
|
||||
|
|
@ -28,7 +28,7 @@ export {
|
|||
avg,
|
||||
min,
|
||||
max,
|
||||
} from './client';
|
||||
} from './client.js';
|
||||
|
||||
// Export all schemas and types
|
||||
export * from './schema';
|
||||
export * from './schema/index.js';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*/
|
||||
|
||||
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
||||
import { getDb, closeDb } from './client';
|
||||
import { getDb, closeDb } from './client.js';
|
||||
import {
|
||||
decks,
|
||||
cards,
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
aiGenerations,
|
||||
userStats,
|
||||
dailyProgress,
|
||||
} from './schema';
|
||||
} from './schema/index.js';
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { createClient } from './client';
|
||||
import { createClient } from './client.js';
|
||||
import path from 'path';
|
||||
|
||||
async function runMigrations() {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { decks } from './decks';
|
||||
import { decks } from './decks.js';
|
||||
|
||||
// AI generation status enum
|
||||
export const aiGenerationStatusEnum = pgEnum('ai_generation_status', [
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
unique,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { cards } from './cards';
|
||||
import { cards } from './cards.js';
|
||||
|
||||
// Progress status enum (SM-2 algorithm states)
|
||||
export const progressStatusEnum = pgEnum('progress_status', [
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { decks } from './decks';
|
||||
import { cardProgress } from './cardProgress';
|
||||
import { decks } from './decks.js';
|
||||
import { cardProgress } from './cardProgress.js';
|
||||
|
||||
// Card type enum
|
||||
export const cardTypeEnum = pgEnum('card_type', ['text', 'flashcard', 'quiz', 'mixed']);
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import {
|
|||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { cards } from './cards';
|
||||
import { studySessions } from './studySessions';
|
||||
import { aiGenerations } from './aiGenerations';
|
||||
import { cards } from './cards.js';
|
||||
import { studySessions } from './studySessions.js';
|
||||
import { aiGenerations } from './aiGenerations.js';
|
||||
|
||||
export const decks = pgTable(
|
||||
'decks',
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
// Export all schemas
|
||||
export * from './decks';
|
||||
export * from './cards';
|
||||
export * from './studySessions';
|
||||
export * from './cardProgress';
|
||||
export * from './deckTemplates';
|
||||
export * from './aiGenerations';
|
||||
export * from './userStats';
|
||||
export * from './dailyProgress';
|
||||
export * from './decks.js';
|
||||
export * from './cards.js';
|
||||
export * from './studySessions.js';
|
||||
export * from './cardProgress.js';
|
||||
export * from './deckTemplates.js';
|
||||
export * from './aiGenerations.js';
|
||||
export * from './userStats.js';
|
||||
export * from './dailyProgress.js';
|
||||
|
||||
// Re-export relations for use with Drizzle query builder
|
||||
export { decksRelations } from './decks';
|
||||
export { cardsRelations } from './cards';
|
||||
export { studySessionsRelations } from './studySessions';
|
||||
export { cardProgressRelations } from './cardProgress';
|
||||
export { aiGenerationsRelations } from './aiGenerations';
|
||||
export { decksRelations } from './decks.js';
|
||||
export { cardsRelations } from './cards.js';
|
||||
export { studySessionsRelations } from './studySessions.js';
|
||||
export { cardProgressRelations } from './cardProgress.js';
|
||||
export { aiGenerationsRelations } from './aiGenerations.js';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { decks } from './decks';
|
||||
import { decks } from './decks.js';
|
||||
|
||||
// Study mode enum
|
||||
export const studyModeEnum = pgEnum('study_mode', ['all', 'new', 'review', 'favorites', 'random']);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getDb, closeDb } from './client';
|
||||
import { deckTemplates } from './schema';
|
||||
import { getDb, closeDb } from './client.js';
|
||||
import { deckTemplates } from './schema/index.js';
|
||||
|
||||
/**
|
||||
* Seed the database with initial data
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Usage: pnpm db:test
|
||||
*/
|
||||
|
||||
import { getDb, closeDb, sql } from './client';
|
||||
import { getDb, closeDb, sql } from './client.js';
|
||||
|
||||
async function testConnection() {
|
||||
console.log('Testing database connection...\n');
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
|
|
|
|||
2888
pnpm-lock.yaml
generated
2888
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue