feat(auth): add avatar upload with S3/MinIO and subscription plans seed

- Add StorageModule for avatar uploads via S3/MinIO
- Create presigned URL endpoint for direct browser uploads
- Create direct upload endpoint (multipart/form-data)
- Add manacore-storage bucket to shared-storage package
- Add manacore-storage bucket to docker-compose.dev.yml
- Create subscription plans seed script (pnpm db:seed:plans)
- Plans: Free (150 credits), Pro (2000/€9.99/mo), Enterprise (10000/€49/mo)
- Update TODO list with completed tasks
This commit is contained in:
Till-JS 2026-02-13 23:06:24 +01:00
parent 1e025b7e72
commit c2842e2546
15 changed files with 756 additions and 102 deletions

View file

@ -21,10 +21,12 @@
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:seed:dev": "tsx src/db/seed-dev-user.ts",
"db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts"
"db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts",
"db:seed:plans": "tsx src/db/seeds/seed-subscription-plans.ts"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@manacore/shared-storage": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
@ -33,6 +35,7 @@
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.1.0",
"@nestjs/throttler": "^6.2.1",
"@types/multer": "^2.0.0",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"better-auth": "^1.4.3",
@ -47,6 +50,7 @@
"helmet": "^8.0.0",
"jose": "^6.1.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.0.9",
"nodemailer": "^7.0.12",
"postgres": "^3.4.5",

View file

@ -12,6 +12,7 @@ import { FeedbackModule } from './feedback/feedback.module';
import { HealthModule } from './health/health.module';
import { ReferralsModule } from './referrals/referrals.module';
import { SettingsModule } from './settings/settings.module';
import { StorageModule } from './storage/storage.module';
import { TagsModule } from './tags/tags.module';
import { MeModule } from './me/me.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
@ -45,6 +46,7 @@ import { LoggerModule } from './common/logger';
HealthModule,
ReferralsModule,
SettingsModule,
StorageModule,
TagsModule,
MeModule,
StripeModule,

View file

@ -72,5 +72,9 @@ export default () => ({
geminiApiKey: env.GOOGLE_GENAI_API_KEY || '',
},
storage: {
publicUrl: env.MANACORE_STORAGE_PUBLIC_URL || '',
},
baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''),
});

View file

@ -55,6 +55,9 @@ const envSchema = z.object({
// AI
GOOGLE_GENAI_API_KEY: z.string().optional(),
// Storage
MANACORE_STORAGE_PUBLIC_URL: z.string().optional(),
// Base URL for callbacks
BASE_URL: z.string().url().optional(),

View file

@ -0,0 +1,184 @@
/**
* Seed subscription plans with Stripe Price IDs
*
* This script creates/updates the default subscription plans in the database.
* Plans are idempotent - running multiple times won't create duplicates.
*
* Usage:
* pnpm db:seed:plans
*
* Prerequisites:
* 1. Create products and prices in Stripe Dashboard
* 2. Set STRIPE_* environment variables with the price IDs
*
* Stripe Products to create:
* - Mana Free (price: 0 EUR)
* - Mana Pro (prices: 9.99 EUR/month, 99 EUR/year)
* - Mana Enterprise (contact sales)
*/
import 'dotenv/config';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { eq } from 'drizzle-orm';
import { plans } from '../schema/subscriptions.schema';
// Environment configuration
const DATABASE_URL =
process.env.DATABASE_URL || 'postgresql://manacore:manacore@localhost:5432/manacore_auth';
// Stripe Price IDs from environment (or defaults for development)
const STRIPE_CONFIG = {
// Free plan (no Stripe price needed)
FREE_PRODUCT_ID: process.env.STRIPE_FREE_PRODUCT_ID || '',
// Pro plan
PRO_PRODUCT_ID: process.env.STRIPE_PRO_PRODUCT_ID || '',
PRO_PRICE_MONTHLY: process.env.STRIPE_PRO_PRICE_MONTHLY || '', // e.g., price_xxx
PRO_PRICE_YEARLY: process.env.STRIPE_PRO_PRICE_YEARLY || '', // e.g., price_xxx
// Enterprise plan
ENTERPRISE_PRODUCT_ID: process.env.STRIPE_ENTERPRISE_PRODUCT_ID || '',
ENTERPRISE_PRICE_MONTHLY: process.env.STRIPE_ENTERPRISE_PRICE_MONTHLY || '',
ENTERPRISE_PRICE_YEARLY: process.env.STRIPE_ENTERPRISE_PRICE_YEARLY || '',
};
// Plan definitions
const PLANS = [
{
name: 'Free',
description: 'Kostenlos starten mit grundlegenden Features',
monthlyCredits: 150,
priceMonthlyEuroCents: 0,
priceYearlyEuroCents: 0,
stripePriceIdMonthly: null,
stripePriceIdYearly: null,
stripeProductId: STRIPE_CONFIG.FREE_PRODUCT_ID || null,
features: [
'150 Credits pro Monat',
'5 tägliche Gratis-Credits',
'Zugang zu allen Apps',
'Basis-Support',
],
maxTeamMembers: null,
maxOrganizations: null,
isDefault: true,
isEnterprise: false,
sortOrder: 0,
},
{
name: 'Pro',
description: 'Für Power-User mit mehr Credits und Features',
monthlyCredits: 2000,
priceMonthlyEuroCents: 999, // 9.99 EUR
priceYearlyEuroCents: 9900, // 99 EUR (2 months free)
stripePriceIdMonthly: STRIPE_CONFIG.PRO_PRICE_MONTHLY || null,
stripePriceIdYearly: STRIPE_CONFIG.PRO_PRICE_YEARLY || null,
stripeProductId: STRIPE_CONFIG.PRO_PRODUCT_ID || null,
features: [
'2.000 Credits pro Monat',
'20 tägliche Gratis-Credits',
'Prioritäts-Support',
'Erweiterte AI-Modelle',
'API-Zugang',
],
maxTeamMembers: 5,
maxOrganizations: 3,
isDefault: false,
isEnterprise: false,
sortOrder: 1,
},
{
name: 'Enterprise',
description: 'Für Teams und Unternehmen mit individuellen Anforderungen',
monthlyCredits: 10000,
priceMonthlyEuroCents: 4900, // 49 EUR
priceYearlyEuroCents: 49000, // 490 EUR (2 months free)
stripePriceIdMonthly: STRIPE_CONFIG.ENTERPRISE_PRICE_MONTHLY || null,
stripePriceIdYearly: STRIPE_CONFIG.ENTERPRISE_PRICE_YEARLY || null,
stripeProductId: STRIPE_CONFIG.ENTERPRISE_PRODUCT_ID || null,
features: [
'10.000 Credits pro Monat',
'Unbegrenzte tägliche Credits',
'Dedizierter Account Manager',
'SLA-garantierte Verfügbarkeit',
'Custom AI-Modelle',
'SSO / SAML Integration',
'Admin-Dashboard',
],
maxTeamMembers: null, // Unlimited
maxOrganizations: null, // Unlimited
isDefault: false,
isEnterprise: true,
sortOrder: 2,
},
];
async function seedPlans() {
console.log('Seeding subscription plans...');
console.log(`Database: ${DATABASE_URL.replace(/:[^@]+@/, ':***@')}`);
const client = postgres(DATABASE_URL);
const db = drizzle(client);
try {
for (const plan of PLANS) {
// Check if plan exists
const [existing] = await db.select().from(plans).where(eq(plans.name, plan.name)).limit(1);
if (existing) {
// Update existing plan
await db
.update(plans)
.set({
...plan,
updatedAt: new Date(),
})
.where(eq(plans.id, existing.id));
console.log(`✓ Updated plan: ${plan.name}`);
} else {
// Insert new plan
await db.insert(plans).values({
...plan,
} as any);
console.log(`✓ Created plan: ${plan.name}`);
}
}
// List all plans
const allPlans = await db.select().from(plans).orderBy(plans.sortOrder);
console.log('\nAll subscription plans:');
console.table(
allPlans.map((p) => ({
name: p.name,
credits: p.monthlyCredits,
monthly: `${(p.priceMonthlyEuroCents / 100).toFixed(2)}`,
yearly: `${(p.priceYearlyEuroCents / 100).toFixed(2)}`,
stripeMonthly: p.stripePriceIdMonthly || '(not set)',
stripeYearly: p.stripePriceIdYearly || '(not set)',
default: p.isDefault,
}))
);
console.log('\n✅ Subscription plans seeded successfully!');
if (!STRIPE_CONFIG.PRO_PRICE_MONTHLY || !STRIPE_CONFIG.PRO_PRICE_YEARLY) {
console.log('\n⚠ Warning: Stripe Price IDs not configured.');
console.log(' Set these environment variables:');
console.log(' - STRIPE_PRO_PRODUCT_ID');
console.log(' - STRIPE_PRO_PRICE_MONTHLY');
console.log(' - STRIPE_PRO_PRICE_YEARLY');
console.log(' - STRIPE_ENTERPRISE_PRODUCT_ID');
console.log(' - STRIPE_ENTERPRISE_PRICE_MONTHLY');
console.log(' - STRIPE_ENTERPRISE_PRICE_YEARLY');
}
} catch (error) {
console.error('Error seeding plans:', error);
process.exit(1);
} finally {
await client.end();
}
}
// Run if called directly
seedPlans();

View file

@ -0,0 +1,3 @@
export { StorageModule } from './storage.module';
export { StorageService } from './storage.service';
export { StorageController } from './storage.controller';

View file

@ -0,0 +1,113 @@
import {
Controller,
Post,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { StorageService } from './storage.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
interface GetUploadUrlDto {
filename: string;
}
@ApiTags('storage')
@Controller('storage')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class StorageController {
constructor(private readonly storageService: StorageService) {}
/**
* Get a presigned URL for avatar upload
*
* Returns a presigned URL that the client can use to upload
* the avatar directly to S3/MinIO. This is the recommended approach
* for frontend uploads as it's more efficient.
*/
@Post('avatar/upload-url')
@ApiOperation({
summary: 'Get presigned URL for avatar upload',
description:
'Returns a presigned URL for direct upload to storage. Use this URL to PUT the file.',
})
@ApiResponse({
status: 200,
description: 'Returns presigned upload URL',
schema: {
type: 'object',
properties: {
uploadUrl: { type: 'string', description: 'PUT this URL with the file' },
fileUrl: { type: 'string', description: 'Public URL after upload' },
key: { type: 'string', description: 'Storage key' },
expiresIn: { type: 'number', description: 'URL expires in seconds' },
},
},
})
@ApiResponse({ status: 400, description: 'Invalid file type or storage not configured' })
async getAvatarUploadUrl(
@CurrentUser() user: CurrentUserData,
@Body() dto: GetUploadUrlDto
): Promise<{
uploadUrl: string;
fileUrl: string;
key: string;
expiresIn: number;
}> {
if (!dto.filename) {
throw new BadRequestException('filename is required');
}
return this.storageService.getAvatarUploadUrl(user.userId, dto.filename);
}
/**
* Upload avatar directly (multipart/form-data)
*
* Alternative to presigned URLs. The file is uploaded to the backend
* which then uploads it to S3/MinIO. Simpler but less efficient for
* large files.
*/
@Post('avatar')
@UseInterceptors(
FileInterceptor('file', {
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
})
)
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Upload avatar directly',
description: 'Upload avatar file directly to the server',
})
@ApiResponse({
status: 201,
description: 'Avatar uploaded successfully',
schema: {
type: 'object',
properties: {
url: { type: 'string', description: 'Public URL of the uploaded avatar' },
key: { type: 'string', description: 'Storage key' },
},
},
})
@ApiResponse({ status: 400, description: 'Invalid file type or size' })
async uploadAvatar(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File
): Promise<{ url: string; key: string }> {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.storageService.uploadAvatar(user.userId, file.buffer, file.originalname);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { StorageService } from './storage.service';
import { StorageController } from './storage.controller';
@Module({
imports: [ConfigModule],
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -0,0 +1,176 @@
import { Injectable, Logger, BadRequestException, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createManaCoreStorage,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
} from '@manacore/shared-storage';
import type { StorageClient } from '@manacore/shared-storage';
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class StorageService implements OnModuleInit {
private readonly logger = new Logger(StorageService.name);
private storage: StorageClient | null = null;
private readonly publicUrl: string | undefined;
constructor(private readonly configService: ConfigService) {
this.publicUrl = this.configService.get<string>('storage.publicUrl');
}
async onModuleInit() {
try {
this.storage = createManaCoreStorage(this.publicUrl);
this.logger.log('Storage service initialized');
} catch (error) {
this.logger.warn(
'Storage service not configured - avatar uploads will be disabled',
error instanceof Error ? error.message : undefined
);
}
}
/**
* Check if storage is available
*/
isAvailable(): boolean {
return this.storage !== null;
}
/**
* Generate a presigned URL for avatar upload
*
* @param userId - User ID
* @param filename - Original filename
* @returns Presigned upload URL and the final file URL
*/
async getAvatarUploadUrl(
userId: string,
filename: string
): Promise<{
uploadUrl: string;
fileUrl: string;
key: string;
expiresIn: number;
}> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
// Validate file extension
const ext = filename.split('.').pop()?.toLowerCase();
if (!ext || !validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
}
// Generate unique key for avatar
const key = `avatars/${userId}/${Date.now()}.${ext}`;
const contentType = getContentType(filename);
// Get presigned upload URL (1 hour expiry)
const expiresIn = 3600;
const uploadUrl = await this.storage.getUploadUrl(key, {
expiresIn,
});
// Construct the final public URL
const fileUrl = await this.getPublicUrl(key);
this.logger.debug('Generated avatar upload URL', { userId, key });
return {
uploadUrl,
fileUrl,
key,
expiresIn,
};
}
/**
* Upload avatar directly (for server-side uploads)
*
* @param userId - User ID
* @param buffer - File buffer
* @param filename - Original filename
* @returns Public URL of the uploaded avatar
*/
async uploadAvatar(
userId: string,
buffer: Buffer,
filename: string
): Promise<{ url: string; key: string }> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
// Validate file extension
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
}
// Validate file size
if (!validateFileSize(buffer.length, MAX_AVATAR_SIZE)) {
throw new BadRequestException(
`File too large. Maximum size: ${MAX_AVATAR_SIZE / 1024 / 1024}MB`
);
}
// Generate unique key for avatar
const ext = filename.split('.').pop()?.toLowerCase() || 'jpg';
const key = `avatars/${userId}/${Date.now()}.${ext}`;
// Upload file
const result = await this.storage.upload(key, buffer, {
contentType: getContentType(filename),
public: true,
cacheControl: 'public, max-age=31536000', // 1 year cache
});
const url = result.url || (await this.getPublicUrl(key));
this.logger.log('Avatar uploaded', { userId, key });
return { url, key };
}
/**
* Delete avatar
*
* @param key - Storage key of the avatar
*/
async deleteAvatar(key: string): Promise<void> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
await this.storage.delete(key);
this.logger.log('Avatar deleted', { key });
}
/**
* Get public URL for a key
*/
private async getPublicUrl(key: string): Promise<string> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
// If we have a configured public URL, use it
if (this.publicUrl) {
return `${this.publicUrl}/${key}`;
}
// Check if the storage has a public URL configured
const publicUrl = this.storage.getPublicUrl(key);
if (publicUrl) {
return publicUrl;
}
// Otherwise, get a presigned URL for reading
return this.storage.getDownloadUrl(key, { expiresIn: 86400 * 365 }); // 1 year
}
}