mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat: add email service and storage module + fix runtime env vars
## Runtime Environment Fix - Updated all web app hooks.server.ts to use $env/dynamic/private - This allows Docker containers to inject env vars at runtime - Updated docker-compose.staging.yml with HTTPS staging domains - Fixes Mixed Content errors when accessing staging via domains ## New Features - Added email service to mana-core-auth for sending emails - Added storage module to chat backend 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6239cc7749
commit
3fa7b027aa
17 changed files with 1225 additions and 78 deletions
|
|
@ -57,6 +57,12 @@ STRIPE_SECRET_KEY=sk_test_YOUR_KEY
|
|||
STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY
|
||||
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET
|
||||
|
||||
# Email (Brevo/SendinBlue) - GDPR compliant EU provider
|
||||
# Get your API key from https://app.brevo.com/settings/keys/api
|
||||
BREVO_API_KEY=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
|
||||
BREVO_FROM_EMAIL=noreply@manacore.app
|
||||
BREVO_FROM_NAME=Mana Core
|
||||
|
||||
# ============================================
|
||||
# CHAT PROJECT
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -27,15 +27,18 @@
|
|||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/shared-errors": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@types/multer": "^1.4.11",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.77.0",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SpaceModule } from './space/space.module';
|
|||
import { DocumentModule } from './document/document.module';
|
||||
import { ModelModule } from './model/model.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -23,6 +24,7 @@ import { HealthModule } from './health/health.module';
|
|||
DocumentModule,
|
||||
ModelModule,
|
||||
HealthModule,
|
||||
StorageModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
3
apps/chat/apps/backend/src/storage/index.ts
Normal file
3
apps/chat/apps/backend/src/storage/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './storage.module';
|
||||
export * from './storage.service';
|
||||
export * from './storage.controller';
|
||||
137
apps/chat/apps/backend/src/storage/storage.controller.ts
Normal file
137
apps/chat/apps/backend/src/storage/storage.controller.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
interface PresignedUploadRequest {
|
||||
filename: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
@Controller('api/storage')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StorageController {
|
||||
constructor(private readonly storageService: StorageService) {}
|
||||
|
||||
/**
|
||||
* Upload a file directly
|
||||
*/
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadFile(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('folder') folder?: string
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
const result = await this.storageService.uploadFile(
|
||||
user.userId,
|
||||
file.originalname,
|
||||
file.buffer,
|
||||
{
|
||||
folder,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for client-side upload
|
||||
*/
|
||||
@Post('presigned-upload')
|
||||
async getPresignedUpload(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: PresignedUploadRequest
|
||||
) {
|
||||
if (!body.filename) {
|
||||
throw new BadRequestException('Filename is required');
|
||||
}
|
||||
|
||||
const result = await this.storageService.getPresignedUploadUrl(user.userId, body.filename, {
|
||||
folder: body.folder,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for downloading
|
||||
*/
|
||||
@Get('download/:key(*)')
|
||||
async getDownloadUrl(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
|
||||
// Ensure user can only access their own files
|
||||
if (!key.startsWith(`users/${user.userId}/`)) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const exists = await this.storageService.fileExists(key);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const url = await this.storageService.getPresignedDownloadUrl(key);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { url },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file
|
||||
*/
|
||||
@Delete(':key(*)')
|
||||
async deleteFile(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
|
||||
// Ensure user can only delete their own files
|
||||
if (!key.startsWith(`users/${user.userId}/`)) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const exists = await this.storageService.fileExists(key);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
await this.storageService.deleteFile(key);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'File deleted',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's files
|
||||
*/
|
||||
@Get('list')
|
||||
async listFiles(@CurrentUser() user: CurrentUserData, @Body('folder') folder?: string) {
|
||||
const files = await this.storageService.listUserFiles(user.userId, folder);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { files },
|
||||
};
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/backend/src/storage/storage.module.ts
Normal file
10
apps/chat/apps/backend/src/storage/storage.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
import { StorageController } from './storage.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [StorageController],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
152
apps/chat/apps/backend/src/storage/storage.service.ts
Normal file
152
apps/chat/apps/backend/src/storage/storage.service.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
createChatStorage,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
validateFileSize,
|
||||
validateFileExtension,
|
||||
IMAGE_EXTENSIONS,
|
||||
DOCUMENT_EXTENSIONS,
|
||||
AUDIO_EXTENSIONS,
|
||||
} from '@manacore/shared-storage';
|
||||
import type { StorageClient, UploadResult } from '@manacore/shared-storage';
|
||||
|
||||
export interface FileUploadResult {
|
||||
key: string;
|
||||
url?: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PresignedUploadData {
|
||||
uploadUrl: string;
|
||||
key: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...AUDIO_EXTENSIONS];
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private storage: StorageClient | null = null;
|
||||
|
||||
private getStorage(): StorageClient {
|
||||
if (!this.storage) {
|
||||
this.storage = createChatStorage();
|
||||
}
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to storage
|
||||
*/
|
||||
async uploadFile(
|
||||
userId: string,
|
||||
filename: string,
|
||||
data: Buffer,
|
||||
options?: { folder?: string; public?: boolean }
|
||||
): Promise<FileUploadResult> {
|
||||
// Validate file size (MAX_FILE_SIZE is in bytes)
|
||||
if (!validateFileSize(data.length, MAX_FILE_SIZE / (1024 * 1024))) {
|
||||
throw new Error(`File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`);
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
|
||||
throw new Error(
|
||||
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = getContentType(filename);
|
||||
const key = generateUserFileKey(userId, filename, options?.folder);
|
||||
|
||||
const storage = this.getStorage();
|
||||
const result: UploadResult = await storage.upload(key, data, {
|
||||
contentType,
|
||||
public: options?.public ?? false,
|
||||
});
|
||||
|
||||
this.logger.log(`File uploaded: ${key} (${data.length} bytes)`);
|
||||
|
||||
return {
|
||||
key: result.key,
|
||||
url: result.url,
|
||||
contentType,
|
||||
size: data.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for uploading (client-side upload)
|
||||
*/
|
||||
async getPresignedUploadUrl(
|
||||
userId: string,
|
||||
filename: string,
|
||||
options?: { folder?: string; expiresIn?: number }
|
||||
): Promise<PresignedUploadData> {
|
||||
// Validate file extension
|
||||
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
|
||||
throw new Error(
|
||||
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const key = generateUserFileKey(userId, filename, options?.folder);
|
||||
const expiresIn = options?.expiresIn ?? 3600; // 1 hour default
|
||||
|
||||
const storage = this.getStorage();
|
||||
const uploadUrl = await storage.getUploadUrl(key, { expiresIn });
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
key,
|
||||
expiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for downloading
|
||||
*/
|
||||
async getPresignedDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
const storage = this.getStorage();
|
||||
return storage.getDownloadUrl(key, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from storage
|
||||
*/
|
||||
async downloadFile(key: string): Promise<Buffer> {
|
||||
const storage = this.getStorage();
|
||||
return storage.download(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
*/
|
||||
async deleteFile(key: string): Promise<void> {
|
||||
const storage = this.getStorage();
|
||||
await storage.delete(key);
|
||||
this.logger.log(`File deleted: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async fileExists(key: string): Promise<boolean> {
|
||||
const storage = this.getStorage();
|
||||
return storage.exists(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* List files for a user
|
||||
*/
|
||||
async listUserFiles(userId: string, folder?: string): Promise<string[]> {
|
||||
const storage = this.getStorage();
|
||||
const prefix = folder ? `users/${userId}/${folder}/` : `users/${userId}/`;
|
||||
const files = await storage.list(prefix);
|
||||
return files.map((f) => f.key);
|
||||
}
|
||||
}
|
||||
287
pnpm-lock.yaml
generated
287
pnpm-lock.yaml
generated
|
|
@ -325,6 +325,9 @@ importers:
|
|||
'@manacore/shared-nestjs-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-nestjs-auth
|
||||
'@manacore/shared-storage':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-storage
|
||||
'@nestjs/common':
|
||||
specifier: ^10.4.15
|
||||
version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
|
|
@ -337,6 +340,9 @@ importers:
|
|||
'@nestjs/platform-express':
|
||||
specifier: ^10.4.15
|
||||
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
|
||||
'@types/multer':
|
||||
specifier: ^1.4.11
|
||||
version: 1.4.13
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
|
|
@ -352,6 +358,9 @@ importers:
|
|||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.7)(expo-sqlite@15.2.14(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(kysely@0.28.8)(postgres@3.4.7)(react@19.1.0)
|
||||
multer:
|
||||
specifier: ^1.4.5-lts.1
|
||||
version: 1.4.5-lts.2
|
||||
openai:
|
||||
specifier: ^4.77.0
|
||||
version: 4.104.0(ws@8.18.3)(zod@3.25.76)
|
||||
|
|
@ -367,7 +376,7 @@ importers:
|
|||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^10.4.9
|
||||
version: 10.4.9(esbuild@0.27.0)
|
||||
version: 10.4.9(esbuild@0.19.12)
|
||||
'@nestjs/schematics':
|
||||
specifier: ^10.2.3
|
||||
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
|
||||
|
|
@ -400,7 +409,7 @@ importers:
|
|||
version: 0.5.21
|
||||
ts-loader:
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
|
||||
version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12))
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
|
||||
|
|
@ -3454,7 +3463,7 @@ importers:
|
|||
version: 9.39.1
|
||||
'@nestjs/cli':
|
||||
specifier: ^10.4.9
|
||||
version: 10.4.9(esbuild@0.19.12)
|
||||
version: 10.4.9(esbuild@0.27.0)
|
||||
'@nestjs/schematics':
|
||||
specifier: ^10.2.3
|
||||
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
|
||||
|
|
@ -3490,7 +3499,7 @@ importers:
|
|||
version: 0.5.21
|
||||
ts-loader:
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12))
|
||||
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
|
||||
|
|
@ -4189,6 +4198,9 @@ importers:
|
|||
|
||||
services/mana-core-auth:
|
||||
dependencies:
|
||||
'@getbrevo/brevo':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
'@google/generative-ai':
|
||||
specifier: ^0.24.1
|
||||
version: 0.24.1
|
||||
|
|
@ -6817,6 +6829,9 @@ packages:
|
|||
'@formatjs/intl-localematcher@0.6.2':
|
||||
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
|
||||
|
||||
'@getbrevo/brevo@3.0.1':
|
||||
resolution: {integrity: sha512-BS5hlgb9qPHhXqjV+VbEOciygnsEVZV8BgoX+JYpD+I+R9u3U05y/euqdmk8nATfcEUCUlQq+aWdWOWTF4cEjQ==}
|
||||
|
||||
'@google/genai@1.30.0':
|
||||
resolution: {integrity: sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
|
@ -10736,6 +10751,9 @@ packages:
|
|||
blake3-wasm@2.1.5:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
|
||||
bluebird@3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
|
||||
blurhash@2.0.5:
|
||||
resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
|
||||
|
||||
|
|
@ -17286,6 +17304,9 @@ packages:
|
|||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rewire@7.0.0:
|
||||
resolution: {integrity: sha512-DyyNyzwMtGYgu0Zl/ya0PR/oaunM+VuCuBxCuhYJHHaV0V+YvYa3bBGxb5OZ71vndgmp1pYY8F4YOwQo1siRGw==}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
|
|
@ -22294,7 +22315,7 @@ snapshots:
|
|||
wrap-ansi: 7.0.0
|
||||
ws: 8.18.3
|
||||
optionalDependencies:
|
||||
expo-router: 6.0.15(dux2nvtiztnejw7mxzfaajqvh4)
|
||||
expo-router: 6.0.15(nttrd3tw67nnyhowcwgdzipb5e)
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
|
|
@ -23044,6 +23065,15 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@getbrevo/brevo@3.0.1':
|
||||
dependencies:
|
||||
axios: 1.13.2
|
||||
bluebird: 3.7.2
|
||||
rewire: 7.0.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@google/genai@1.30.0':
|
||||
dependencies:
|
||||
google-auth-library: 10.5.0
|
||||
|
|
@ -23599,6 +23629,43 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
'@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@jest/console': 30.2.0
|
||||
'@jest/pattern': 30.0.1
|
||||
'@jest/reporters': 30.2.0
|
||||
'@jest/test-result': 30.2.0
|
||||
'@jest/transform': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
'@types/node': 22.19.1
|
||||
ansi-escapes: 4.3.2
|
||||
chalk: 4.1.2
|
||||
ci-info: 4.3.1
|
||||
exit-x: 0.2.2
|
||||
graceful-fs: 4.2.11
|
||||
jest-changed-files: 30.2.0
|
||||
jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-haste-map: 30.2.0
|
||||
jest-message-util: 30.2.0
|
||||
jest-regex-util: 30.0.1
|
||||
jest-resolve: 30.2.0
|
||||
jest-resolve-dependencies: 30.2.0
|
||||
jest-runner: 30.2.0
|
||||
jest-runtime: 30.2.0
|
||||
jest-snapshot: 30.2.0
|
||||
jest-util: 30.2.0
|
||||
jest-validate: 30.2.0
|
||||
jest-watcher: 30.2.0
|
||||
micromatch: 4.0.8
|
||||
pretty-format: 30.2.0
|
||||
slash: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
optional: true
|
||||
|
||||
'@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))':
|
||||
dependencies:
|
||||
'@jest/console': 30.2.0
|
||||
|
|
@ -26660,6 +26727,19 @@ snapshots:
|
|||
jest: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))
|
||||
optional: true
|
||||
|
||||
'@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
jest-matcher-utils: 30.2.0
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 30.2.0
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
|
||||
react-test-renderer: 19.1.0(react@19.1.0)
|
||||
redent: 3.0.0
|
||||
optionalDependencies:
|
||||
jest: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
optional: true
|
||||
|
||||
'@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
jest-matcher-utils: 30.2.0
|
||||
|
|
@ -29154,6 +29234,8 @@ snapshots:
|
|||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
blurhash@2.0.5: {}
|
||||
|
||||
body-parser@1.20.3:
|
||||
|
|
@ -32550,6 +32632,53 @@ snapshots:
|
|||
- supports-color
|
||||
optional: true
|
||||
|
||||
expo-router@6.0.15(nttrd3tw67nnyhowcwgdzipb5e):
|
||||
dependencies:
|
||||
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@expo/schema-utils': 0.1.7
|
||||
'@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0)
|
||||
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
client-only: 0.0.1
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
|
||||
expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
expo-server: 1.0.4
|
||||
fast-deep-equal: 3.1.3
|
||||
invariant: 2.2.4
|
||||
nanoid: 3.3.11
|
||||
query-string: 7.1.3
|
||||
react: 19.1.0
|
||||
react-fast-compare: 3.2.2
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
|
||||
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
semver: 7.6.3
|
||||
server-only: 0.0.1
|
||||
sf-symbols-typescript: 2.1.0
|
||||
shallowequal: 1.1.0
|
||||
use-latest-callback: 0.2.6(react@19.1.0)
|
||||
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12))
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-masked-view/masked-view'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
expo-router@6.0.15(qjp3usx4acoq47dkosl6pmu254):
|
||||
dependencies:
|
||||
'@expo/metro-runtime': 6.1.2(expo@54.0.13)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -34673,6 +34802,26 @@ snapshots:
|
|||
- ts-node
|
||||
optional: true
|
||||
|
||||
jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
'@jest/test-result': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
chalk: 4.1.2
|
||||
exit-x: 0.2.2
|
||||
import-local: 3.2.0
|
||||
jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-util: 30.2.0
|
||||
jest-validate: 30.2.0
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
optional: true
|
||||
|
||||
jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
|
|
@ -34858,6 +35007,41 @@ snapshots:
|
|||
- supports-color
|
||||
optional: true
|
||||
|
||||
jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@jest/get-type': 30.1.0
|
||||
'@jest/pattern': 30.0.1
|
||||
'@jest/test-sequencer': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
babel-jest: 30.2.0(@babel/core@7.28.5)
|
||||
chalk: 4.1.2
|
||||
ci-info: 4.3.1
|
||||
deepmerge: 4.3.1
|
||||
glob: 10.5.0
|
||||
graceful-fs: 4.2.11
|
||||
jest-circus: 30.2.0
|
||||
jest-docblock: 30.2.0
|
||||
jest-environment-node: 30.2.0
|
||||
jest-regex-util: 30.0.1
|
||||
jest-resolve: 30.2.0
|
||||
jest-runner: 30.2.0
|
||||
jest-util: 30.2.0
|
||||
jest-validate: 30.2.0
|
||||
micromatch: 4.0.8
|
||||
parse-json: 5.2.0
|
||||
pretty-format: 30.2.0
|
||||
slash: 3.0.0
|
||||
strip-json-comments: 3.1.1
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.1
|
||||
esbuild-register: 3.6.0(esbuild@0.19.12)
|
||||
ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
|
@ -35527,6 +35711,20 @@ snapshots:
|
|||
- ts-node
|
||||
optional: true
|
||||
|
||||
jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
'@jest/types': 30.2.0
|
||||
import-local: 3.2.0
|
||||
jest-cli: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
optional: true
|
||||
|
||||
jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
|
|
@ -39298,6 +39496,16 @@ snapshots:
|
|||
webpack: 5.100.2(esbuild@0.27.0)
|
||||
webpack-sources: 3.3.3
|
||||
|
||||
react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12)):
|
||||
dependencies:
|
||||
acorn-loose: 8.5.2
|
||||
neo-async: 2.6.2
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
webpack: 5.97.1(esbuild@0.19.12)
|
||||
webpack-sources: 3.3.3
|
||||
optional: true
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.0):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
|
|
@ -39634,6 +39842,12 @@ snapshots:
|
|||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rewire@7.0.0:
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rimraf@2.6.3:
|
||||
|
|
@ -40549,17 +40763,6 @@ snapshots:
|
|||
ansi-escapes: 4.3.2
|
||||
supports-hyperlinks: 2.3.0
|
||||
|
||||
terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
terser: 5.44.1
|
||||
webpack: 5.100.2(esbuild@0.19.12)
|
||||
optionalDependencies:
|
||||
esbuild: 0.19.12
|
||||
|
||||
terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
|
@ -40804,16 +41007,6 @@ snapshots:
|
|||
babel-jest: 30.2.0(@babel/core@7.28.5)
|
||||
jest-util: 30.2.0
|
||||
|
||||
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)):
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
enhanced-resolve: 5.18.3
|
||||
micromatch: 4.0.8
|
||||
semver: 7.7.3
|
||||
source-map: 0.7.6
|
||||
typescript: 5.9.3
|
||||
webpack: 5.100.2(esbuild@0.19.12)
|
||||
|
||||
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)):
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
|
|
@ -40834,6 +41027,16 @@ snapshots:
|
|||
typescript: 5.9.3
|
||||
webpack: 5.100.2
|
||||
|
||||
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)):
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
enhanced-resolve: 5.18.3
|
||||
micromatch: 4.0.8
|
||||
semver: 7.7.3
|
||||
source-map: 0.7.6
|
||||
typescript: 5.9.3
|
||||
webpack: 5.97.1(esbuild@0.19.12)
|
||||
|
||||
ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
|
|
@ -41803,38 +42006,6 @@ snapshots:
|
|||
- esbuild
|
||||
- uglify-js
|
||||
|
||||
webpack@5.100.2(esbuild@0.19.12):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.8
|
||||
'@types/json-schema': 7.0.15
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
'@webassemblyjs/wasm-edit': 1.14.1
|
||||
'@webassemblyjs/wasm-parser': 1.14.1
|
||||
acorn: 8.15.0
|
||||
acorn-import-phases: 1.0.4(acorn@8.15.0)
|
||||
browserslist: 4.28.0
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.18.3
|
||||
es-module-lexer: 1.7.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
glob-to-regexp: 0.4.1
|
||||
graceful-fs: 4.2.11
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
loader-runner: 4.3.1
|
||||
mime-types: 2.1.35
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.0
|
||||
terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12))
|
||||
watchpack: 2.4.4
|
||||
webpack-sources: 3.3.3
|
||||
transitivePeerDependencies:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
- uglify-js
|
||||
|
||||
webpack@5.100.2(esbuild@0.27.0):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ const APP_CONFIGS = [
|
|||
STRIPE_SECRET_KEY: (env) => env.STRIPE_SECRET_KEY,
|
||||
STRIPE_PUBLISHABLE_KEY: (env) => env.STRIPE_PUBLISHABLE_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: (env) => env.STRIPE_WEBHOOK_SECRET,
|
||||
BREVO_API_KEY: (env) => env.BREVO_API_KEY || '',
|
||||
BREVO_FROM_EMAIL: (env) => env.BREVO_FROM_EMAIL || 'noreply@manacore.app',
|
||||
BREVO_FROM_NAME: (env) => env.BREVO_FROM_NAME || 'Mana Core',
|
||||
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
|
||||
CREDITS_SIGNUP_BONUS: (env) => env.CREDITS_SIGNUP_BONUS,
|
||||
CREDITS_DAILY_FREE: (env) => env.CREDITS_DAILY_FREE,
|
||||
|
|
@ -98,6 +101,12 @@ const APP_CONFIGS = [
|
|||
GOOGLE_GENAI_API_KEY: (env) => env.GOOGLE_GENAI_API_KEY,
|
||||
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
DATABASE_URL: (env) => env.CHAT_DATABASE_URL,
|
||||
// S3 Storage (MinIO local, Hetzner production)
|
||||
S3_ENDPOINT: (env) => env.S3_ENDPOINT,
|
||||
S3_REGION: (env) => env.S3_REGION,
|
||||
S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY,
|
||||
S3_SECRET_KEY: (env) => env.S3_SECRET_KEY,
|
||||
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^3.0.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { APP_FILTER } from '@nestjs/core';
|
|||
import configuration from './config/configuration';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CreditsModule } from './credits/credits.module';
|
||||
import { EmailModule } from './email/email.module';
|
||||
import { FeedbackModule } from './feedback/feedback.module';
|
||||
import { ReferralsModule } from './referrals/referrals.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
|
|
@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||
AiModule,
|
||||
AuthModule,
|
||||
CreditsModule,
|
||||
EmailModule,
|
||||
FeedbackModule,
|
||||
HealthModule,
|
||||
ReferralsModule,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { getDb } from '../db/connection';
|
|||
import { organizations, members, invitations } from '../db/schema/organizations.schema';
|
||||
import { users, sessions, accounts, verificationTokens, jwks } from '../db/schema/auth.schema';
|
||||
import type { JWTPayloadContext } from './types/better-auth.types';
|
||||
import { sendPasswordResetEmail, sendOrganizationInvitationEmail } from '../email/email-sender';
|
||||
|
||||
/**
|
||||
* JWT Custom Payload Interface
|
||||
|
|
@ -96,19 +97,8 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
*
|
||||
* @see https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
*/
|
||||
sendResetPassword: async ({ user, url, token }) => {
|
||||
// TODO: Implement email sending service (e.g., Resend, SendGrid)
|
||||
// For now, log the reset URL for development
|
||||
console.log('[Password Reset] User:', user.email);
|
||||
console.log('[Password Reset] Reset URL:', url);
|
||||
console.log('[Password Reset] Token:', token);
|
||||
|
||||
// In production, send an email like:
|
||||
// await sendEmail({
|
||||
// to: user.email,
|
||||
// subject: 'Reset your password',
|
||||
// html: `<a href="${url}">Reset your password</a>`
|
||||
// });
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendPasswordResetEmail(user.email, url, user.name);
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -143,14 +133,16 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
|
||||
// Email invitation handler
|
||||
async sendInvitationEmail(data) {
|
||||
const { email, organization } = data;
|
||||
const { email, organization, inviter } = data;
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
|
||||
const invitationUrl = `${baseUrl}/accept-invitation?id=${data.id}`;
|
||||
|
||||
// TODO: Implement email sending service
|
||||
console.log('TODO: Send invitation email', {
|
||||
to: email,
|
||||
organization: organization.name,
|
||||
invitationId: data.id,
|
||||
});
|
||||
await sendOrganizationInvitationEmail(
|
||||
email,
|
||||
organization.name,
|
||||
invitationUrl,
|
||||
inviter?.user?.name
|
||||
);
|
||||
},
|
||||
|
||||
// Custom roles and permissions
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export default () => ({
|
|||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
},
|
||||
|
||||
email: {
|
||||
brevoApiKey: process.env.BREVO_API_KEY || '',
|
||||
fromEmail: process.env.BREVO_FROM_EMAIL || 'noreply@manacore.app',
|
||||
fromName: process.env.BREVO_FROM_NAME || 'Mana Core',
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:3000',
|
||||
|
|
|
|||
322
services/mana-core-auth/src/email/email-sender.ts
Normal file
322
services/mana-core-auth/src/email/email-sender.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* Standalone email sender for Better Auth callbacks
|
||||
*
|
||||
* This module provides email sending functionality that can be used
|
||||
* outside of the NestJS DI context (e.g., in Better Auth callbacks).
|
||||
*/
|
||||
|
||||
import * as brevo from '@getbrevo/brevo';
|
||||
|
||||
interface EmailConfig {
|
||||
apiKey?: string;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
}
|
||||
|
||||
function getEmailConfig(): EmailConfig {
|
||||
return {
|
||||
apiKey: process.env.BREVO_API_KEY,
|
||||
fromEmail: process.env.BREVO_FROM_EMAIL || 'noreply@manacore.app',
|
||||
fromName: process.env.BREVO_FROM_NAME || 'Mana Core',
|
||||
};
|
||||
}
|
||||
|
||||
let apiInstance: brevo.TransactionalEmailsApi | null = null;
|
||||
|
||||
function getApiInstance(): brevo.TransactionalEmailsApi | null {
|
||||
const config = getEmailConfig();
|
||||
|
||||
if (!config.apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!apiInstance) {
|
||||
apiInstance = new brevo.TransactionalEmailsApi();
|
||||
apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, config.apiKey);
|
||||
}
|
||||
|
||||
return apiInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
resetUrl: string,
|
||||
userName?: string
|
||||
): Promise<void> {
|
||||
const config = getEmailConfig();
|
||||
const api = getApiInstance();
|
||||
|
||||
if (!api) {
|
||||
console.log('[DEV MODE] Password reset email would be sent to:', email);
|
||||
console.log('[DEV MODE] Reset URL:', resetUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||
sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName };
|
||||
sendSmtpEmail.to = [{ email }];
|
||||
sendSmtpEmail.subject = 'Reset your Mana Core password';
|
||||
sendSmtpEmail.htmlContent = getPasswordResetTemplate(resetUrl, userName);
|
||||
sendSmtpEmail.textContent = `
|
||||
Reset your password
|
||||
|
||||
Hi${userName ? ` ${userName}` : ''},
|
||||
|
||||
You requested to reset your password. Click the link below to set a new password:
|
||||
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
- The Mana Core Team
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
await api.sendTransacEmail(sendSmtpEmail);
|
||||
console.log(`Password reset email sent to ${email}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send password reset email to ${email}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send organization invitation email
|
||||
*/
|
||||
export async function sendOrganizationInvitationEmail(
|
||||
email: string,
|
||||
organizationName: string,
|
||||
invitationUrl: string,
|
||||
inviterName?: string
|
||||
): Promise<void> {
|
||||
const config = getEmailConfig();
|
||||
const api = getApiInstance();
|
||||
|
||||
if (!api) {
|
||||
console.log('[DEV MODE] Invitation email would be sent to:', email);
|
||||
console.log('[DEV MODE] Organization:', organizationName);
|
||||
console.log('[DEV MODE] Invitation URL:', invitationUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||
sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName };
|
||||
sendSmtpEmail.to = [{ email }];
|
||||
sendSmtpEmail.subject = `You've been invited to join ${organizationName} on Mana Core`;
|
||||
sendSmtpEmail.htmlContent = getInvitationTemplate(organizationName, invitationUrl, inviterName);
|
||||
sendSmtpEmail.textContent = `
|
||||
You've been invited to ${organizationName}
|
||||
|
||||
Hi,
|
||||
|
||||
${inviterName ? `${inviterName} has` : 'You have been'} invited you to join ${organizationName} on Mana Core.
|
||||
|
||||
Click the link below to accept the invitation:
|
||||
|
||||
${invitationUrl}
|
||||
|
||||
This invitation will expire in 7 days.
|
||||
|
||||
- The Mana Core Team
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
await api.sendTransacEmail(sendSmtpEmail);
|
||||
console.log(`Invitation email sent to ${email}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send invitation email to ${email}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification email
|
||||
*/
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
verificationUrl: string,
|
||||
userName?: string
|
||||
): Promise<void> {
|
||||
const config = getEmailConfig();
|
||||
const api = getApiInstance();
|
||||
|
||||
if (!api) {
|
||||
console.log('[DEV MODE] Verification email would be sent to:', email);
|
||||
console.log('[DEV MODE] Verification URL:', verificationUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||
sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName };
|
||||
sendSmtpEmail.to = [{ email }];
|
||||
sendSmtpEmail.subject = 'Verify your Mana Core email address';
|
||||
sendSmtpEmail.htmlContent = getVerificationTemplate(verificationUrl, userName);
|
||||
sendSmtpEmail.textContent = `
|
||||
Verify your email address
|
||||
|
||||
Hi${userName ? ` ${userName}` : ''},
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
|
||||
${verificationUrl}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
If you didn't create a Mana Core account, you can safely ignore this email.
|
||||
|
||||
- The Mana Core Team
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
await api.sendTransacEmail(sendSmtpEmail);
|
||||
console.log(`Verification email sent to ${email}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send verification email to ${email}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getPasswordResetTemplate(resetUrl: string, userName?: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset your password</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 40px; text-align: center;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset your password</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
|
||||
Hi${userName ? ` ${userName}` : ''},<br><br>
|
||||
You requested to reset your password. Click the button below to set a new password:
|
||||
</p>
|
||||
<a href="${resetUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
|
||||
Reset Password
|
||||
</a>
|
||||
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
|
||||
This link will expire in 1 hour.<br>
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
||||
© ${new Date().getFullYear()} Mana Core. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function getInvitationTemplate(
|
||||
organizationName: string,
|
||||
invitationUrl: string,
|
||||
inviterName?: string
|
||||
): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Organization Invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 40px; text-align: center;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You've been invited!</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
|
||||
${inviterName ? `${inviterName} has` : 'You have been'} invited you to join <strong>${organizationName}</strong> on Mana Core.
|
||||
</p>
|
||||
<a href="${invitationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
|
||||
This invitation will expire in 7 days.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
||||
© ${new Date().getFullYear()} Mana Core. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function getVerificationTemplate(verificationUrl: string, userName?: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify your email</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 40px; text-align: center;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Verify your email</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
|
||||
Hi${userName ? ` ${userName}` : ''},<br><br>
|
||||
Thanks for signing up! Please verify your email address by clicking the button below:
|
||||
</p>
|
||||
<a href="${verificationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
|
||||
Verify Email
|
||||
</a>
|
||||
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
|
||||
This link will expire in 24 hours.<br>
|
||||
If you didn't create a Mana Core account, you can safely ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
||||
© ${new Date().getFullYear()} Mana Core. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
9
services/mana-core-auth/src/email/email.module.ts
Normal file
9
services/mana-core-auth/src/email/email.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
320
services/mana-core-auth/src/email/email.service.ts
Normal file
320
services/mana-core-auth/src/email/email.service.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as brevo from '@getbrevo/brevo';
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
textContent?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetEmailData {
|
||||
email: string;
|
||||
name?: string;
|
||||
resetUrl: string;
|
||||
}
|
||||
|
||||
export interface InvitationEmailData {
|
||||
email: string;
|
||||
organizationName: string;
|
||||
inviterName?: string;
|
||||
invitationUrl: string;
|
||||
}
|
||||
|
||||
export interface VerificationEmailData {
|
||||
email: string;
|
||||
name?: string;
|
||||
verificationUrl: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
private readonly apiInstance: brevo.TransactionalEmailsApi;
|
||||
private readonly fromEmail: string;
|
||||
private readonly fromName: string;
|
||||
private readonly isConfigured: boolean;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('email.brevoApiKey');
|
||||
this.fromEmail = this.configService.get<string>('email.fromEmail') || 'noreply@manacore.app';
|
||||
this.fromName = this.configService.get<string>('email.fromName') || 'Mana Core';
|
||||
|
||||
this.apiInstance = new brevo.TransactionalEmailsApi();
|
||||
|
||||
if (apiKey) {
|
||||
this.apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, apiKey);
|
||||
this.isConfigured = true;
|
||||
this.logger.log('Email service configured with Brevo');
|
||||
} else {
|
||||
this.isConfigured = false;
|
||||
this.logger.warn('Email service not configured - BREVO_API_KEY is missing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a transactional email via Brevo
|
||||
*/
|
||||
async sendEmail(options: SendEmailOptions): Promise<boolean> {
|
||||
if (!this.isConfigured) {
|
||||
this.logger.warn(`[DEV MODE] Would send email to ${options.to}: ${options.subject}`);
|
||||
this.logger.debug(`Email content: ${options.htmlContent}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||
sendSmtpEmail.sender = { email: this.fromEmail, name: this.fromName };
|
||||
sendSmtpEmail.to = [{ email: options.to }];
|
||||
sendSmtpEmail.subject = options.subject;
|
||||
sendSmtpEmail.htmlContent = options.htmlContent;
|
||||
|
||||
if (options.textContent) {
|
||||
sendSmtpEmail.textContent = options.textContent;
|
||||
}
|
||||
|
||||
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
|
||||
this.logger.log(`Email sent successfully to ${options.to}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email to ${options.to}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async sendPasswordResetEmail(data: PasswordResetEmailData): Promise<boolean> {
|
||||
const subject = 'Reset your Mana Core password';
|
||||
const htmlContent = this.getPasswordResetTemplate(data);
|
||||
const textContent = `
|
||||
Reset your password
|
||||
|
||||
Hi${data.name ? ` ${data.name}` : ''},
|
||||
|
||||
You requested to reset your password. Click the link below to set a new password:
|
||||
|
||||
${data.resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
- The Mana Core Team
|
||||
`.trim();
|
||||
|
||||
return this.sendEmail({
|
||||
to: data.email,
|
||||
subject,
|
||||
htmlContent,
|
||||
textContent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send organization invitation email
|
||||
*/
|
||||
async sendInvitationEmail(data: InvitationEmailData): Promise<boolean> {
|
||||
const subject = `You've been invited to join ${data.organizationName} on Mana Core`;
|
||||
const htmlContent = this.getInvitationTemplate(data);
|
||||
const textContent = `
|
||||
You've been invited to ${data.organizationName}
|
||||
|
||||
Hi,
|
||||
|
||||
${data.inviterName ? `${data.inviterName} has` : 'You have been'} invited you to join ${data.organizationName} on Mana Core.
|
||||
|
||||
Click the link below to accept the invitation:
|
||||
|
||||
${data.invitationUrl}
|
||||
|
||||
This invitation will expire in 7 days.
|
||||
|
||||
- The Mana Core Team
|
||||
`.trim();
|
||||
|
||||
return this.sendEmail({
|
||||
to: data.email,
|
||||
subject,
|
||||
htmlContent,
|
||||
textContent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification email
|
||||
*/
|
||||
async sendVerificationEmail(data: VerificationEmailData): Promise<boolean> {
|
||||
const subject = 'Verify your Mana Core email address';
|
||||
const htmlContent = this.getVerificationTemplate(data);
|
||||
const textContent = `
|
||||
Verify your email address
|
||||
|
||||
Hi${data.name ? ` ${data.name}` : ''},
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
|
||||
${data.verificationUrl}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
If you didn't create a Mana Core account, you can safely ignore this email.
|
||||
|
||||
- The Mana Core Team
|
||||
`.trim();
|
||||
|
||||
return this.sendEmail({
|
||||
to: data.email,
|
||||
subject,
|
||||
htmlContent,
|
||||
textContent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset email template
|
||||
*/
|
||||
private getPasswordResetTemplate(data: PasswordResetEmailData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset your password</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 40px; text-align: center;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset your password</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
|
||||
Hi${data.name ? ` ${data.name}` : ''},<br><br>
|
||||
You requested to reset your password. Click the button below to set a new password:
|
||||
</p>
|
||||
<a href="${data.resetUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
|
||||
Reset Password
|
||||
</a>
|
||||
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
|
||||
This link will expire in 1 hour.<br>
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
||||
© ${new Date().getFullYear()} Mana Core. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization invitation email template
|
||||
*/
|
||||
private getInvitationTemplate(data: InvitationEmailData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Organization Invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 40px; text-align: center;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You've been invited!</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
|
||||
${data.inviterName ? `${data.inviterName} has` : 'You have been'} invited you to join <strong>${data.organizationName}</strong> on Mana Core.
|
||||
</p>
|
||||
<a href="${data.invitationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
|
||||
This invitation will expire in 7 days.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
||||
© ${new Date().getFullYear()} Mana Core. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Email verification template
|
||||
*/
|
||||
private getVerificationTemplate(data: VerificationEmailData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify your email</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 40px; text-align: center;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Verify your email</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
|
||||
Hi${data.name ? ` ${data.name}` : ''},<br><br>
|
||||
Thanks for signing up! Please verify your email address by clicking the button below:
|
||||
</p>
|
||||
<a href="${data.verificationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
|
||||
Verify Email
|
||||
</a>
|
||||
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
|
||||
This link will expire in 24 hours.<br>
|
||||
If you didn't create a Mana Core account, you can safely ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
||||
© ${new Date().getFullYear()} Mana Core. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
2
services/mana-core-auth/src/email/index.ts
Normal file
2
services/mana-core-auth/src/email/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './email.service';
|
||||
export * from './email.module';
|
||||
Loading…
Add table
Add a link
Reference in a new issue