chore: archive finance, mail, moodlit apps and rename voxel-lava

- Move finance, mail, moodlit to apps-archived for later development
- Rename games/voxel-lava to games/voxelava

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 13:13:15 +01:00
parent c3c272abc9
commit ace7fa8f7f
427 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,108 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ComposeService } from './compose.service';
import { CreateDraftDto, UpdateDraftDto, SendEmailDto, DraftQueryDto } from './dto/compose.dto';
@Controller()
@UseGuards(JwtAuthGuard)
export class ComposeController {
constructor(private readonly composeService: ComposeService) {}
// ==================== Drafts ====================
@Get('drafts')
async findAllDrafts(@CurrentUser() user: CurrentUserData, @Query() query: DraftQueryDto) {
const drafts = await this.composeService.findDraftsByUserId(user.userId, query);
const total = await this.composeService.countDrafts(user.userId, query.accountId);
return { drafts, total };
}
@Get('drafts/:id')
async findDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const draft = await this.composeService.findDraftById(id, user.userId);
if (!draft) {
return { draft: null };
}
return { draft };
}
@Post('drafts')
async createDraft(@CurrentUser() user: CurrentUserData, @Body() dto: CreateDraftDto) {
const draft = await this.composeService.createDraft({
...dto,
userId: user.userId,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
});
return { draft };
}
@Patch('drafts/:id')
async updateDraft(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDraftDto
) {
const draft = await this.composeService.updateDraft(id, user.userId, {
...dto,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
});
return { draft };
}
@Delete('drafts/:id')
async deleteDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.composeService.deleteDraft(id, user.userId);
return { success: true };
}
@Post('drafts/:id/send')
async sendDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const result = await this.composeService.sendDraft(id, user.userId);
return result;
}
// ==================== Direct Send ====================
@Post('send')
async sendEmail(@CurrentUser() user: CurrentUserData, @Body() dto: SendEmailDto) {
const result = await this.composeService.sendEmail(user.userId, dto);
return result;
}
// ==================== Reply/Forward ====================
@Post('emails/:id/reply')
async createReply(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const draft = await this.composeService.createReplyDraft(user.userId, id, 'reply');
return { draft };
}
@Post('emails/:id/reply-all')
async createReplyAll(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const draft = await this.composeService.createReplyDraft(user.userId, id, 'reply-all');
return { draft };
}
@Post('emails/:id/forward')
async createForward(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const draft = await this.composeService.createReplyDraft(user.userId, id, 'forward');
return { draft };
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ComposeController } from './compose.controller';
import { ComposeService } from './compose.service';
import { AccountModule } from '../account/account.module';
import { EmailModule } from '../email/email.module';
@Module({
imports: [AccountModule, EmailModule],
controllers: [ComposeController],
providers: [ComposeService],
exports: [ComposeService],
})
export class ComposeModule {}

View file

@ -0,0 +1,363 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { drafts, type Draft, type NewDraft, emailAccounts, type EmailAddress } from '../db/schema';
import { AccountService } from '../account/account.service';
import { EmailService } from '../email/email.service';
import * as nodemailer from 'nodemailer';
export interface DraftFilters {
accountId?: string;
limit?: number;
offset?: number;
}
@Injectable()
export class ComposeService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private accountService: AccountService,
private emailService: EmailService
) {}
// ==================== Draft Management ====================
async findDraftsByUserId(userId: string, filters: DraftFilters = {}): Promise<Draft[]> {
const { accountId, limit = 50, offset = 0 } = filters;
let conditions = [eq(drafts.userId, userId)];
if (accountId) {
conditions.push(eq(drafts.accountId, accountId));
}
return this.db
.select()
.from(drafts)
.where(and(...conditions))
.orderBy(desc(drafts.updatedAt))
.limit(limit)
.offset(offset);
}
async findDraftById(id: string, userId: string): Promise<Draft | null> {
const [draft] = await this.db
.select()
.from(drafts)
.where(and(eq(drafts.id, id), eq(drafts.userId, userId)));
return draft || null;
}
async createDraft(data: NewDraft): Promise<Draft> {
const [draft] = await this.db.insert(drafts).values(data).returning();
return draft;
}
async updateDraft(id: string, userId: string, data: Partial<NewDraft>): Promise<Draft> {
const [draft] = await this.db
.update(drafts)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(drafts.id, id), eq(drafts.userId, userId)))
.returning();
if (!draft) {
throw new NotFoundException('Draft not found');
}
return draft;
}
async deleteDraft(id: string, userId: string): Promise<void> {
const draft = await this.findDraftById(id, userId);
if (!draft) {
throw new NotFoundException('Draft not found');
}
await this.db.delete(drafts).where(and(eq(drafts.id, id), eq(drafts.userId, userId)));
}
async countDrafts(userId: string, accountId?: string): Promise<number> {
let conditions = [eq(drafts.userId, userId)];
if (accountId) {
conditions.push(eq(drafts.accountId, accountId));
}
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(drafts)
.where(and(...conditions));
return Number(result[0]?.count || 0);
}
// ==================== Send Email ====================
async sendEmail(
userId: string,
data: {
accountId: string;
subject?: string;
toAddresses: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
replyToEmailId?: string;
replyType?: string;
}
): Promise<{ success: boolean; messageId?: string }> {
// Get the account
const account = await this.accountService.findById(data.accountId, userId);
if (!account) {
throw new NotFoundException('Email account not found');
}
// Build the email
const mailOptions: nodemailer.SendMailOptions = {
from: {
name: account.name,
address: account.email,
},
to: data.toAddresses.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
cc: data.ccAddresses?.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
bcc: data.bccAddresses?.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
subject: data.subject || '(No Subject)',
html: data.bodyHtml,
text: data.bodyPlain,
};
// Add reply headers if replying
if (data.replyToEmailId) {
const originalEmail = await this.emailService.findById(data.replyToEmailId, userId);
if (originalEmail) {
mailOptions.inReplyTo = originalEmail.messageId;
mailOptions.references = originalEmail.messageId;
}
}
// Send based on provider
switch (account.provider) {
case 'imap':
return this.sendViaSMTP(account, mailOptions);
case 'gmail':
return this.sendViaGmail(account, mailOptions);
case 'outlook':
return this.sendViaOutlook(account, mailOptions);
default:
throw new BadRequestException(`Unknown provider: ${account.provider}`);
}
}
async sendDraft(
draftId: string,
userId: string
): Promise<{ success: boolean; messageId?: string }> {
const draft = await this.findDraftById(draftId, userId);
if (!draft) {
throw new NotFoundException('Draft not found');
}
if (!draft.toAddresses || draft.toAddresses.length === 0) {
throw new BadRequestException('Draft must have at least one recipient');
}
const result = await this.sendEmail(userId, {
accountId: draft.accountId,
subject: draft.subject || undefined,
toAddresses: draft.toAddresses,
ccAddresses: draft.ccAddresses || undefined,
bccAddresses: draft.bccAddresses || undefined,
bodyHtml: draft.bodyHtml || undefined,
bodyPlain: draft.bodyPlain || undefined,
replyToEmailId: draft.replyToEmailId || undefined,
replyType: draft.replyType || undefined,
});
// Delete draft after successful send
if (result.success) {
await this.deleteDraft(draftId, userId);
}
return result;
}
// ==================== Provider-specific send methods ====================
private async sendViaSMTP(
account: typeof emailAccounts.$inferSelect,
mailOptions: nodemailer.SendMailOptions
): Promise<{ success: boolean; messageId?: string }> {
if (!account.smtpHost || !account.smtpPort) {
throw new BadRequestException('SMTP settings not configured for this account');
}
// Get decrypted password
const password = await this.accountService.getDecryptedPassword(account.id, account.userId);
if (!password) {
throw new BadRequestException('Account password not found');
}
// Create transporter
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
secure: account.smtpSecurity === 'ssl',
auth: {
user: account.email,
pass: password,
},
tls: {
rejectUnauthorized: false, // Allow self-signed certs in dev
},
});
try {
const info = await transporter.sendMail(mailOptions);
return { success: true, messageId: info.messageId };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send email';
throw new BadRequestException(`SMTP send failed: ${message}`);
}
}
private async sendViaGmail(
account: typeof emailAccounts.$inferSelect,
mailOptions: nodemailer.SendMailOptions
): Promise<{ success: boolean; messageId?: string }> {
if (!account.accessToken) {
throw new BadRequestException('Gmail access token not found');
}
// Use OAuth2 with Gmail
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
type: 'OAuth2',
user: account.email,
accessToken: account.accessToken,
},
});
try {
const info = await transporter.sendMail(mailOptions);
return { success: true, messageId: info.messageId };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send email';
throw new BadRequestException(`Gmail send failed: ${message}`);
}
}
private async sendViaOutlook(
account: typeof emailAccounts.$inferSelect,
mailOptions: nodemailer.SendMailOptions
): Promise<{ success: boolean; messageId?: string }> {
if (!account.accessToken) {
throw new BadRequestException('Outlook access token not found');
}
// Use Microsoft Graph API to send
const { Client } = await import('@microsoft/microsoft-graph-client');
const client = Client.init({
authProvider: (done) => {
done(null, account.accessToken!);
},
});
// Convert to Graph API format
const message = {
subject: mailOptions.subject,
body: {
contentType: mailOptions.html ? 'HTML' : 'Text',
content: mailOptions.html || mailOptions.text || '',
},
toRecipients: (mailOptions.to as string[])?.map((email) => ({
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
})),
ccRecipients: (mailOptions.cc as string[])?.map((email) => ({
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
})),
bccRecipients: (mailOptions.bcc as string[])?.map((email) => ({
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
})),
};
try {
await client.api('/me/sendMail').post({ message, saveToSentItems: true });
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send email';
throw new BadRequestException(`Outlook send failed: ${message}`);
}
}
// ==================== Reply/Forward Helpers ====================
async createReplyDraft(
userId: string,
emailId: string,
replyType: 'reply' | 'reply-all' | 'forward'
): Promise<Draft> {
const originalEmail = await this.emailService.findById(emailId, userId);
if (!originalEmail) {
throw new NotFoundException('Original email not found');
}
let toAddresses: EmailAddress[] = [];
let ccAddresses: EmailAddress[] = [];
let subject = originalEmail.subject || '';
let bodyHtml = '';
switch (replyType) {
case 'reply':
toAddresses = [
{ email: originalEmail.fromAddress || '', name: originalEmail.fromName || undefined },
];
subject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
break;
case 'reply-all':
toAddresses = [
{ email: originalEmail.fromAddress || '', name: originalEmail.fromName || undefined },
];
ccAddresses =
originalEmail.toAddresses?.filter((a) => a.email !== originalEmail.fromAddress) || [];
if (originalEmail.ccAddresses) {
ccAddresses = [...ccAddresses, ...originalEmail.ccAddresses];
}
subject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
break;
case 'forward':
subject = subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`;
break;
}
// Build quoted content
const date = originalEmail.sentAt?.toLocaleString() || 'Unknown date';
const from = originalEmail.fromName
? `${originalEmail.fromName} <${originalEmail.fromAddress}>`
: originalEmail.fromAddress;
bodyHtml = `
<br><br>
<div style="border-left: 2px solid #ccc; padding-left: 10px; margin-left: 10px;">
<p><strong>On ${date}, ${from} wrote:</strong></p>
${originalEmail.bodyHtml || `<pre>${originalEmail.bodyPlain || ''}</pre>`}
</div>
`;
return this.createDraft({
userId,
accountId: originalEmail.accountId,
replyToEmailId: emailId,
replyType,
subject,
toAddresses,
ccAddresses,
bodyHtml,
});
}
}

View file

@ -0,0 +1,161 @@
import {
IsString,
IsOptional,
IsUUID,
IsArray,
IsDateString,
ValidateNested,
IsEmail,
IsIn,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class EmailAddressDto {
@IsEmail()
email: string;
@IsString()
@IsOptional()
name?: string;
}
export class CreateDraftDto {
@IsUUID()
accountId: string;
@IsString()
@IsOptional()
subject?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
toAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
ccAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
bccAddresses?: EmailAddressDto[];
@IsString()
@IsOptional()
bodyHtml?: string;
@IsString()
@IsOptional()
bodyPlain?: string;
@IsUUID()
@IsOptional()
replyToEmailId?: string;
@IsString()
@IsOptional()
@IsIn(['reply', 'reply-all', 'forward'])
replyType?: string;
@IsDateString()
@IsOptional()
scheduledAt?: string;
}
export class UpdateDraftDto {
@IsString()
@IsOptional()
subject?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
toAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
ccAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
bccAddresses?: EmailAddressDto[];
@IsString()
@IsOptional()
bodyHtml?: string;
@IsString()
@IsOptional()
bodyPlain?: string;
@IsDateString()
@IsOptional()
scheduledAt?: string;
}
export class SendEmailDto {
@IsUUID()
accountId: string;
@IsString()
@IsOptional()
subject?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
toAddresses: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
ccAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
bccAddresses?: EmailAddressDto[];
@IsString()
@IsOptional()
bodyHtml?: string;
@IsString()
@IsOptional()
bodyPlain?: string;
@IsUUID()
@IsOptional()
replyToEmailId?: string;
@IsString()
@IsOptional()
@IsIn(['reply', 'reply-all', 'forward'])
replyType?: string;
}
export class DraftQueryDto {
@IsUUID()
@IsOptional()
accountId?: string;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
limit?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
offset?: number;
}