mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat: major update with network graphs, themes, todo extensions, and more
## New Features ### Network Graph Visualization (Contacts, Calendar, Todo) - D3.js force simulation for physics-based layout - Zoom & pan with mouse/touchpad - Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus - Filtering by tags, company/location/project, connection strength - Shared components in @manacore/shared-ui ### Central Tags API (mana-core-auth) - CRUD endpoints for tags - Schema: tags table with userId, name, color, app - Shared tag components in @manacore/shared-ui ### Custom Themes System - Theme editor with live preview and color picker - Community theme gallery - Theme sharing (public, unlisted, private) - Backend API in mana-core-auth ### Todo App Extensions - Glass-pill design for task input and items - Settings page with 20+ preferences - Task edit modal with inline editing - Statistics page with visualizations - PWA support with offline capabilities - Multiple kanban boards ### Contacts App Features - Duplicate detection - Photo upload - Batch operations - Enhanced favorites page with multiple view modes - Alphabet view improvements - Search modal ### Help System - @manacore/shared-help-content - @manacore/shared-help-ui - @manacore/shared-help-types ### Other Features - Themes page for all apps - Referral system frontend - CommandBar (global search) - Skeleton loaders - Settings page improvements ## Bug Fixes - Network graph simulation initialization - Database schema TEXT for user_id columns (Better Auth compatibility) - Various styling fixes ## Documentation - Daily report for 2025-12-10 - CI/CD deployment guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
|
|
@ -21,7 +21,6 @@
|
|||
"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,10 +5,11 @@ 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';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { ThemesModule } from './themes/themes.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
|
|
@ -28,11 +29,12 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||
AiModule,
|
||||
AuthModule,
|
||||
CreditsModule,
|
||||
EmailModule,
|
||||
FeedbackModule,
|
||||
HealthModule,
|
||||
ReferralsModule,
|
||||
SettingsModule,
|
||||
TagsModule,
|
||||
ThemesModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ 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
|
||||
|
|
@ -97,8 +96,19 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
*
|
||||
* @see https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
*/
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendPasswordResetEmail(user.email, url, user.name);
|
||||
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>`
|
||||
// });
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -133,16 +143,14 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
|
||||
// Email invitation handler
|
||||
async sendInvitationEmail(data) {
|
||||
const { email, organization, inviter } = data;
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
|
||||
const invitationUrl = `${baseUrl}/accept-invitation?id=${data.id}`;
|
||||
const { email, organization } = data;
|
||||
|
||||
await sendOrganizationInvitationEmail(
|
||||
email,
|
||||
organization.name,
|
||||
invitationUrl,
|
||||
inviter?.user?.name
|
||||
);
|
||||
// TODO: Implement email sending service
|
||||
console.log('TODO: Send invitation email', {
|
||||
to: email,
|
||||
organization: organization.name,
|
||||
invitationId: data.id,
|
||||
});
|
||||
},
|
||||
|
||||
// Custom roles and permissions
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@ 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',
|
||||
|
|
|
|||
|
|
@ -3,3 +3,5 @@ export * from './credits.schema';
|
|||
export * from './feedback.schema';
|
||||
export * from './organizations.schema';
|
||||
export * from './referrals.schema';
|
||||
export * from './tags.schema';
|
||||
export * from './themes.schema';
|
||||
|
|
|
|||
26
services/mana-core-auth/src/db/schema/tags.schema.ts
Normal file
26
services/mana-core-auth/src/db/schema/tags.schema.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Central tags table for all Manacore applications.
|
||||
* Tags created here can be used in Todo, Calendar, Contacts, and other apps.
|
||||
*/
|
||||
export const tags = pgTable(
|
||||
'tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
icon: varchar('icon', { length: 50 }), // Optional: Phosphor Icon name
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tags_user_idx').on(table.userId),
|
||||
unique('tags_user_name_unique').on(table.userId, table.name),
|
||||
]
|
||||
);
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
162
services/mana-core-auth/src/db/schema/themes.schema.ts
Normal file
162
services/mana-core-auth/src/db/schema/themes.schema.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { uuid, text, timestamp, boolean, jsonb, integer, index } from 'drizzle-orm/pg-core';
|
||||
import { users, authSchema } from './auth.schema';
|
||||
|
||||
/**
|
||||
* Custom Themes - Private themes created by users
|
||||
*/
|
||||
export const customThemes = authSchema.table(
|
||||
'custom_themes',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// Theme metadata
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
emoji: text('emoji').default('🎨'),
|
||||
icon: text('icon').default('palette'),
|
||||
|
||||
// Colors (JSONB - ThemeColors interface)
|
||||
lightColors: jsonb('light_colors').notNull(),
|
||||
darkColors: jsonb('dark_colors').notNull(),
|
||||
|
||||
// Base variant this theme was derived from (optional)
|
||||
baseVariant: text('base_variant'), // 'lume' | 'nature' | 'stone' | 'ocean' | null
|
||||
|
||||
// Publishing status
|
||||
isPublished: boolean('is_published').default(false).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('custom_themes_user_idx').on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Community Themes - Public themes shared with all users
|
||||
*/
|
||||
export const communityThemes = authSchema.table(
|
||||
'community_themes',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
authorId: text('author_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
|
||||
// Theme metadata
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
emoji: text('emoji').default('🎨'),
|
||||
icon: text('icon').default('palette'),
|
||||
|
||||
// Colors (JSONB - ThemeColors interface)
|
||||
lightColors: jsonb('light_colors').notNull(),
|
||||
darkColors: jsonb('dark_colors').notNull(),
|
||||
|
||||
// Base variant (for compatibility preview)
|
||||
baseVariant: text('base_variant'),
|
||||
|
||||
// Statistics
|
||||
downloadCount: integer('download_count').default(0).notNull(),
|
||||
ratingSum: integer('rating_sum').default(0).notNull(),
|
||||
ratingCount: integer('rating_count').default(0).notNull(),
|
||||
|
||||
// Moderation status: pending -> approved (or rejected), featured for promoted themes
|
||||
status: text('status').default('pending').notNull(), // 'pending' | 'approved' | 'rejected' | 'featured'
|
||||
isFeatured: boolean('is_featured').default(false).notNull(),
|
||||
|
||||
// Tags for search/filtering
|
||||
tags: jsonb('tags').default([]).notNull(), // string[]
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
publishedAt: timestamp('published_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
authorIdx: index('community_themes_author_idx').on(table.authorId),
|
||||
statusIdx: index('community_themes_status_idx').on(table.status),
|
||||
downloadIdx: index('community_themes_download_idx').on(table.downloadCount),
|
||||
featuredIdx: index('community_themes_featured_idx').on(table.isFeatured),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* User Theme Favorites - Users can favorite community themes
|
||||
*/
|
||||
export const userThemeFavorites = authSchema.table(
|
||||
'user_theme_favorites',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
communityThemeId: uuid('community_theme_id')
|
||||
.references(() => communityThemes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userThemeIdx: index('user_theme_favorites_user_theme_idx').on(
|
||||
table.userId,
|
||||
table.communityThemeId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* User Theme Downloads - Track which users downloaded which themes
|
||||
*/
|
||||
export const userThemeDownloads = authSchema.table(
|
||||
'user_theme_downloads',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
communityThemeId: uuid('community_theme_id')
|
||||
.references(() => communityThemes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userDownloadIdx: index('user_theme_downloads_user_theme_idx').on(
|
||||
table.userId,
|
||||
table.communityThemeId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Theme Ratings - Users can rate community themes (1-5 stars)
|
||||
*/
|
||||
export const themeRatings = authSchema.table(
|
||||
'theme_ratings',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
communityThemeId: uuid('community_theme_id')
|
||||
.references(() => communityThemes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
rating: integer('rating').notNull(), // 1-5
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userRatingIdx: index('theme_ratings_user_theme_idx').on(table.userId, table.communityThemeId),
|
||||
})
|
||||
);
|
||||
|
||||
// Type exports for use in services
|
||||
export type CustomTheme = typeof customThemes.$inferSelect;
|
||||
export type NewCustomTheme = typeof customThemes.$inferInsert;
|
||||
export type CommunityTheme = typeof communityThemes.$inferSelect;
|
||||
export type NewCommunityTheme = typeof communityThemes.$inferInsert;
|
||||
export type UserThemeFavorite = typeof userThemeFavorites.$inferSelect;
|
||||
export type UserThemeDownload = typeof userThemeDownloads.$inferSelect;
|
||||
export type ThemeRating = typeof themeRatings.$inferSelect;
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './email.service';
|
||||
export * from './email.module';
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import { IsOptional, IsString, IsObject, ValidateNested, IsBoolean, IsIn } from 'class-validator';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
// Nav settings
|
||||
|
|
@ -21,6 +29,11 @@ export class ThemeSettingsDto {
|
|||
@IsOptional()
|
||||
@IsString()
|
||||
colorScheme?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
pinnedThemes?: string[];
|
||||
}
|
||||
|
||||
// Global settings update
|
||||
|
|
@ -62,6 +75,7 @@ export interface NavSettings {
|
|||
export interface ThemeSettings {
|
||||
mode: 'light' | 'dark' | 'system';
|
||||
colorScheme: string;
|
||||
pinnedThemes: string[];
|
||||
}
|
||||
|
||||
export interface GlobalSettings {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
// Default settings for new users
|
||||
const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean' },
|
||||
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
|
||||
locale: 'de',
|
||||
};
|
||||
|
||||
|
|
|
|||
18
services/mana-core-auth/src/tags/dto/create-tag.dto.ts
Normal file
18
services/mana-core-auth/src/tags/dto/create-tag.dto.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { IsString, IsOptional, MaxLength, Matches } from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' })
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
icon?: string;
|
||||
}
|
||||
2
services/mana-core-auth/src/tags/dto/index.ts
Normal file
2
services/mana-core-auth/src/tags/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-tag.dto';
|
||||
export * from './update-tag.dto';
|
||||
19
services/mana-core-auth/src/tags/dto/update-tag.dto.ts
Normal file
19
services/mana-core-auth/src/tags/dto/update-tag.dto.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { IsString, IsOptional, MaxLength, Matches } from 'class-validator';
|
||||
|
||||
export class UpdateTagDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' })
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
icon?: string;
|
||||
}
|
||||
4
services/mana-core-auth/src/tags/index.ts
Normal file
4
services/mana-core-auth/src/tags/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './tags.module';
|
||||
export * from './tags.service';
|
||||
export * from './tags.controller';
|
||||
export * from './dto';
|
||||
92
services/mana-core-auth/src/tags/tags.controller.ts
Normal file
92
services/mana-core-auth/src/tags/tags.controller.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { TagsService } from './tags.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';
|
||||
import { CreateTagDto, UpdateTagDto } from './dto';
|
||||
|
||||
@Controller('tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TagsController {
|
||||
constructor(private readonly tagsService: TagsService) {}
|
||||
|
||||
/**
|
||||
* Get all tags for the authenticated user
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.tagsService.findByUserId(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple tags by IDs
|
||||
* Used by apps to resolve tagIds to full tag objects
|
||||
* Query: ?ids=id1,id2,id3
|
||||
*/
|
||||
@Get('by-ids')
|
||||
async getByIds(@CurrentUser() user: CurrentUserData, @Query('ids') ids?: string) {
|
||||
if (!ids) {
|
||||
return [];
|
||||
}
|
||||
const idArray = ids.split(',').filter((id) => id.trim());
|
||||
return this.tagsService.getByIds(idArray, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single tag by ID
|
||||
*/
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.tagsService.findById(id, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
*/
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() createTagDto: CreateTagDto) {
|
||||
return this.tagsService.create(user.userId, createTagDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default tags for the user (if not already created)
|
||||
* Called on first access or explicitly
|
||||
*/
|
||||
@Post('defaults')
|
||||
async createDefaults(@CurrentUser() user: CurrentUserData) {
|
||||
return this.tagsService.createDefaultTags(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tag
|
||||
*/
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() updateTagDto: UpdateTagDto
|
||||
) {
|
||||
return this.tagsService.update(id, user.userId, updateTagDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.tagsService.delete(id, user.userId);
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/tags/tags.module.ts
Normal file
10
services/mana-core-auth/src/tags/tags.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
exports: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
184
services/mana-core-auth/src/tags/tags.service.ts
Normal file
184
services/mana-core-auth/src/tags/tags.service.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { tags } from '../db/schema';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
|
||||
// Default tags created for new users
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Arbeit', color: '#3B82F6', icon: 'Briefcase' },
|
||||
{ name: 'Persönlich', color: '#10B981', icon: 'User' },
|
||||
{ name: 'Familie', color: '#EC4899', icon: 'Heart' },
|
||||
{ name: 'Wichtig', color: '#EF4444', icon: 'Star' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a user
|
||||
*/
|
||||
async findByUserId(userId: string) {
|
||||
const db = this.getDb();
|
||||
return db.select().from(tags).where(eq(tags.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single tag by ID (only if owned by user)
|
||||
*/
|
||||
async findById(id: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
const [tag] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return tag || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple tags by IDs (only those owned by user)
|
||||
* Used by apps to resolve tagIds to full tag objects
|
||||
*/
|
||||
async getByIds(ids: string[], userId: string) {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const db = this.getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(inArray(tags.id, ids), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
*/
|
||||
async create(userId: string, dto: CreateTagDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check for duplicate name
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.userId, userId), eq(tags.name, dto.name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Tag "${dto.name}" already exists`);
|
||||
}
|
||||
|
||||
const [tag] = await db
|
||||
.insert(tags)
|
||||
.values({
|
||||
userId,
|
||||
name: dto.name,
|
||||
color: dto.color || '#3B82F6',
|
||||
icon: dto.icon || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tag
|
||||
*/
|
||||
async update(id: string, userId: string, dto: UpdateTagDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify tag exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Tag not found`);
|
||||
}
|
||||
|
||||
// Check for duplicate name if name is being changed
|
||||
if (dto.name && dto.name !== existing.name) {
|
||||
const [duplicate] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.userId, userId), eq(tags.name, dto.name)))
|
||||
.limit(1);
|
||||
|
||||
if (duplicate) {
|
||||
throw new ConflictException(`Tag "${dto.name}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const [tag] = await db
|
||||
.update(tags)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
*/
|
||||
async delete(id: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify tag exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Tag not found`);
|
||||
}
|
||||
|
||||
await db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default tags for a new user
|
||||
* Called during user registration or first access
|
||||
*/
|
||||
async createDefaultTags(userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check if user already has tags
|
||||
const existingTags = await db.select().from(tags).where(eq(tags.userId, userId)).limit(1);
|
||||
|
||||
if (existingTags.length > 0) {
|
||||
// User already has tags, return existing
|
||||
return this.findByUserId(userId);
|
||||
}
|
||||
|
||||
// Create default tags
|
||||
const createdTags = await db
|
||||
.insert(tags)
|
||||
.values(
|
||||
DEFAULT_TAGS.map((tag) => ({
|
||||
userId,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
icon: tag.icon,
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return createdTags;
|
||||
}
|
||||
}
|
||||
274
services/mana-core-auth/src/themes/dto/index.ts
Normal file
274
services/mana-core-auth/src/themes/dto/index.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsEnum,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* ThemeColors structure matching the frontend ThemeColors interface
|
||||
*/
|
||||
export class ThemeColorsDto {
|
||||
@IsString()
|
||||
primary: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
primaryForeground?: string;
|
||||
|
||||
@IsString()
|
||||
background: string;
|
||||
|
||||
@IsString()
|
||||
foreground: string;
|
||||
|
||||
@IsString()
|
||||
surface: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
surfaceHover?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
surfaceElevated?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
muted?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
mutedForeground?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
border?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
borderStrong?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
secondary?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
secondaryForeground?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
input?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ring?: string;
|
||||
|
||||
@IsString()
|
||||
error: string;
|
||||
|
||||
@IsString()
|
||||
success: string;
|
||||
|
||||
@IsString()
|
||||
warning: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new custom theme
|
||||
*/
|
||||
export class CreateCustomThemeDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
emoji?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
icon?: string;
|
||||
|
||||
@IsObject()
|
||||
@Type(() => ThemeColorsDto)
|
||||
lightColors: ThemeColorsDto;
|
||||
|
||||
@IsObject()
|
||||
@Type(() => ThemeColorsDto)
|
||||
darkColors: ThemeColorsDto;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(['lume', 'nature', 'stone', 'ocean'])
|
||||
baseVariant?: 'lume' | 'nature' | 'stone' | 'ocean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing custom theme
|
||||
*/
|
||||
export class UpdateCustomThemeDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
emoji?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
icon?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Type(() => ThemeColorsDto)
|
||||
lightColors?: ThemeColorsDto;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Type(() => ThemeColorsDto)
|
||||
darkColors?: ThemeColorsDto;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(['lume', 'nature', 'stone', 'ocean'])
|
||||
baseVariant?: 'lume' | 'nature' | 'stone' | 'ocean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a custom theme to the community
|
||||
*/
|
||||
export class PublishThemeDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for browsing community themes
|
||||
*/
|
||||
export class ThemeQueryDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number = 1;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number = 20;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(['popular', 'recent', 'rating', 'downloads'])
|
||||
sort?: 'popular' | 'recent' | 'rating' | 'downloads' = 'popular';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
authorId?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
featuredOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a community theme
|
||||
*/
|
||||
export class RateThemeDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for a custom theme
|
||||
*/
|
||||
export class CustomThemeResponseDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
lightColors: ThemeColorsDto;
|
||||
darkColors: ThemeColorsDto;
|
||||
baseVariant?: string;
|
||||
isPublished: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for a community theme
|
||||
*/
|
||||
export class CommunityThemeResponseDto {
|
||||
id: string;
|
||||
authorId?: string;
|
||||
authorName?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
lightColors: ThemeColorsDto;
|
||||
darkColors: ThemeColorsDto;
|
||||
baseVariant?: string;
|
||||
downloadCount: number;
|
||||
averageRating: number;
|
||||
ratingCount: number;
|
||||
status: string;
|
||||
isFeatured: boolean;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
publishedAt?: Date;
|
||||
// User-specific fields (when authenticated)
|
||||
isFavorited?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated response for community themes
|
||||
*/
|
||||
export class PaginatedCommunityThemesDto {
|
||||
themes: CommunityThemeResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
161
services/mana-core-auth/src/themes/themes.controller.ts
Normal file
161
services/mana-core-auth/src/themes/themes.controller.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ThemesService } from './themes.service';
|
||||
import {
|
||||
CreateCustomThemeDto,
|
||||
UpdateCustomThemeDto,
|
||||
PublishThemeDto,
|
||||
ThemeQueryDto,
|
||||
RateThemeDto,
|
||||
} from './dto';
|
||||
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';
|
||||
|
||||
@Controller()
|
||||
export class ThemesController {
|
||||
constructor(private readonly themesService: ThemesService) {}
|
||||
|
||||
// ==================== Custom Themes ====================
|
||||
|
||||
/**
|
||||
* Create a new custom theme
|
||||
*/
|
||||
@Post('themes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createCustomTheme(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCustomThemeDto) {
|
||||
return this.themesService.createCustomTheme(user.userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom themes for the current user
|
||||
*/
|
||||
@Get('themes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getCustomThemes(@CurrentUser() user: CurrentUserData) {
|
||||
return this.themesService.getCustomThemes(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific custom theme
|
||||
*/
|
||||
@Get('themes/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getCustomTheme(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.themesService.getCustomTheme(user.userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom theme
|
||||
*/
|
||||
@Patch('themes/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async updateCustomTheme(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCustomThemeDto
|
||||
) {
|
||||
return this.themesService.updateCustomTheme(user.userId, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom theme
|
||||
*/
|
||||
@Delete('themes/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteCustomTheme(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.themesService.deleteCustomTheme(user.userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a custom theme to the community
|
||||
*/
|
||||
@Post('themes/:id/publish')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async publishTheme(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: PublishThemeDto
|
||||
) {
|
||||
return this.themesService.publishTheme(user.userId, id, dto);
|
||||
}
|
||||
|
||||
// ==================== Community Themes ====================
|
||||
|
||||
/**
|
||||
* Browse community themes with filtering, sorting, and pagination
|
||||
*/
|
||||
@Get('community-themes')
|
||||
async getCommunityThemes(@Query() query: ThemeQueryDto, @CurrentUser() user?: CurrentUserData) {
|
||||
return this.themesService.getCommunityThemes(query, user?.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's favorite community themes
|
||||
*/
|
||||
@Get('community-themes/favorites')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getFavorites(@CurrentUser() user: CurrentUserData) {
|
||||
return this.themesService.getFavorites(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's downloaded community themes
|
||||
*/
|
||||
@Get('community-themes/downloaded')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getDownloadedThemes(@CurrentUser() user: CurrentUserData) {
|
||||
return this.themesService.getDownloadedThemes(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific community theme
|
||||
*/
|
||||
@Get('community-themes/:id')
|
||||
async getCommunityTheme(@Param('id') id: string, @CurrentUser() user?: CurrentUserData) {
|
||||
return this.themesService.getCommunityTheme(id, user?.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download/install a community theme
|
||||
*/
|
||||
@Post('community-themes/:id/download')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async downloadTheme(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.themesService.downloadTheme(user.userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a community theme (1-5 stars)
|
||||
*/
|
||||
@Post('community-themes/:id/rate')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async rateTheme(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RateThemeDto
|
||||
) {
|
||||
return this.themesService.rateTheme(user.userId, id, dto.rating);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status for a community theme
|
||||
*/
|
||||
@Post('community-themes/:id/favorite')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async toggleFavorite(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.themesService.toggleFavorite(user.userId, id);
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/themes/themes.module.ts
Normal file
10
services/mana-core-auth/src/themes/themes.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ThemesController } from './themes.controller';
|
||||
import { ThemesService } from './themes.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ThemesController],
|
||||
providers: [ThemesService],
|
||||
exports: [ThemesService],
|
||||
})
|
||||
export class ThemesModule {}
|
||||
578
services/mana-core-auth/src/themes/themes.service.ts
Normal file
578
services/mana-core-auth/src/themes/themes.service.ts
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, desc, ilike, inArray, sql } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import {
|
||||
customThemes,
|
||||
communityThemes,
|
||||
userThemeFavorites,
|
||||
userThemeDownloads,
|
||||
themeRatings,
|
||||
users,
|
||||
} from '../db/schema';
|
||||
import {
|
||||
CreateCustomThemeDto,
|
||||
UpdateCustomThemeDto,
|
||||
PublishThemeDto,
|
||||
ThemeQueryDto,
|
||||
CommunityThemeResponseDto,
|
||||
PaginatedCommunityThemesDto,
|
||||
} from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class ThemesService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
// ==================== Custom Themes ====================
|
||||
|
||||
/**
|
||||
* Create a new custom theme for a user
|
||||
*/
|
||||
async createCustomTheme(userId: string, dto: CreateCustomThemeDto) {
|
||||
const [theme] = await this.getDb()
|
||||
.insert(customThemes)
|
||||
.values({
|
||||
userId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
emoji: dto.emoji || '🎨',
|
||||
icon: dto.icon || 'palette',
|
||||
lightColors: dto.lightColors,
|
||||
darkColors: dto.darkColors,
|
||||
baseVariant: dto.baseVariant,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom themes for a user
|
||||
*/
|
||||
async getCustomThemes(userId: string) {
|
||||
return this.getDb()
|
||||
.select()
|
||||
.from(customThemes)
|
||||
.where(eq(customThemes.userId, userId))
|
||||
.orderBy(desc(customThemes.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific custom theme
|
||||
*/
|
||||
async getCustomTheme(userId: string, themeId: string) {
|
||||
const [theme] = await this.getDb()
|
||||
.select()
|
||||
.from(customThemes)
|
||||
.where(and(eq(customThemes.id, themeId), eq(customThemes.userId, userId)));
|
||||
|
||||
if (!theme) {
|
||||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom theme
|
||||
*/
|
||||
async updateCustomTheme(userId: string, themeId: string, dto: UpdateCustomThemeDto) {
|
||||
// Verify ownership
|
||||
await this.getCustomTheme(userId, themeId);
|
||||
|
||||
const [updated] = await this.getDb()
|
||||
.update(customThemes)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(customThemes.id, themeId), eq(customThemes.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom theme
|
||||
*/
|
||||
async deleteCustomTheme(userId: string, themeId: string) {
|
||||
// Verify ownership
|
||||
await this.getCustomTheme(userId, themeId);
|
||||
|
||||
await this.getDb()
|
||||
.delete(customThemes)
|
||||
.where(and(eq(customThemes.id, themeId), eq(customThemes.userId, userId)));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a custom theme to the community
|
||||
*/
|
||||
async publishTheme(userId: string, themeId: string, dto: PublishThemeDto) {
|
||||
const theme = await this.getCustomTheme(userId, themeId);
|
||||
|
||||
if (theme.isPublished) {
|
||||
throw new ConflictException('Theme is already published');
|
||||
}
|
||||
|
||||
// Create community theme entry (pending approval)
|
||||
const [communityTheme] = await this.getDb()
|
||||
.insert(communityThemes)
|
||||
.values({
|
||||
authorId: userId,
|
||||
name: theme.name,
|
||||
description: dto.description || theme.description,
|
||||
emoji: theme.emoji,
|
||||
icon: theme.icon,
|
||||
lightColors: theme.lightColors,
|
||||
darkColors: theme.darkColors,
|
||||
baseVariant: theme.baseVariant,
|
||||
tags: dto.tags || [],
|
||||
status: 'pending',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Mark custom theme as published
|
||||
await this.getDb()
|
||||
.update(customThemes)
|
||||
.set({ isPublished: true, updatedAt: new Date() })
|
||||
.where(eq(customThemes.id, themeId));
|
||||
|
||||
return communityTheme;
|
||||
}
|
||||
|
||||
// ==================== Community Themes ====================
|
||||
|
||||
/**
|
||||
* Browse community themes with filtering and pagination
|
||||
*/
|
||||
async getCommunityThemes(
|
||||
query: ThemeQueryDto,
|
||||
userId?: string
|
||||
): Promise<PaginatedCommunityThemesDto> {
|
||||
const { page = 1, limit = 20, sort = 'popular', search, tags, authorId, featuredOnly } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where conditions
|
||||
const conditions = [eq(communityThemes.status, 'approved')];
|
||||
|
||||
if (featuredOnly) {
|
||||
conditions.push(eq(communityThemes.isFeatured, true));
|
||||
}
|
||||
|
||||
if (authorId) {
|
||||
conditions.push(eq(communityThemes.authorId, authorId));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(ilike(communityThemes.name, `%${search}%`));
|
||||
}
|
||||
|
||||
// Build order by
|
||||
let orderBy;
|
||||
switch (sort) {
|
||||
case 'recent':
|
||||
orderBy = desc(communityThemes.publishedAt);
|
||||
break;
|
||||
case 'rating':
|
||||
orderBy = desc(
|
||||
sql`CASE WHEN ${communityThemes.ratingCount} > 0 THEN ${communityThemes.ratingSum}::float / ${communityThemes.ratingCount} ELSE 0 END`
|
||||
);
|
||||
break;
|
||||
case 'downloads':
|
||||
orderBy = desc(communityThemes.downloadCount);
|
||||
break;
|
||||
case 'popular':
|
||||
default:
|
||||
// Popular = combination of downloads and rating
|
||||
orderBy = desc(
|
||||
sql`${communityThemes.downloadCount} + (CASE WHEN ${communityThemes.ratingCount} > 0 THEN ${communityThemes.ratingSum}::float / ${communityThemes.ratingCount} * 10 ELSE 0 END)`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Get themes with author info
|
||||
const themesQuery = this.getDb()
|
||||
.select({
|
||||
theme: communityThemes,
|
||||
authorName: users.name,
|
||||
})
|
||||
.from(communityThemes)
|
||||
.leftJoin(users, eq(communityThemes.authorId, users.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const themes = await themesQuery;
|
||||
|
||||
// Get total count
|
||||
const [{ count }] = await this.getDb()
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(communityThemes)
|
||||
.where(and(...conditions));
|
||||
|
||||
// If user is authenticated, get their favorites, downloads, and ratings
|
||||
let userFavorites = new Set<string>();
|
||||
let userDownloads = new Set<string>();
|
||||
let userRatingsMap = new Map<string, number>();
|
||||
|
||||
if (userId) {
|
||||
const themeIds = themes.map((t) => t.theme.id);
|
||||
|
||||
if (themeIds.length > 0) {
|
||||
const favorites = await this.getDb()
|
||||
.select()
|
||||
.from(userThemeFavorites)
|
||||
.where(
|
||||
and(
|
||||
eq(userThemeFavorites.userId, userId),
|
||||
inArray(userThemeFavorites.communityThemeId, themeIds)
|
||||
)
|
||||
);
|
||||
userFavorites = new Set(favorites.map((f) => f.communityThemeId));
|
||||
|
||||
const downloads = await this.getDb()
|
||||
.select()
|
||||
.from(userThemeDownloads)
|
||||
.where(
|
||||
and(
|
||||
eq(userThemeDownloads.userId, userId),
|
||||
inArray(userThemeDownloads.communityThemeId, themeIds)
|
||||
)
|
||||
);
|
||||
userDownloads = new Set(downloads.map((d) => d.communityThemeId));
|
||||
|
||||
const ratings = await this.getDb()
|
||||
.select()
|
||||
.from(themeRatings)
|
||||
.where(
|
||||
and(eq(themeRatings.userId, userId), inArray(themeRatings.communityThemeId, themeIds))
|
||||
);
|
||||
ratings.forEach((r) => userRatingsMap.set(r.communityThemeId, r.rating));
|
||||
}
|
||||
}
|
||||
|
||||
// Transform to response DTOs
|
||||
const responseThemes: CommunityThemeResponseDto[] = themes.map(({ theme, authorName }) => ({
|
||||
id: theme.id,
|
||||
authorId: theme.authorId ?? undefined,
|
||||
authorName: authorName || undefined,
|
||||
name: theme.name,
|
||||
description: theme.description || undefined,
|
||||
emoji: theme.emoji || '🎨',
|
||||
icon: theme.icon || 'palette',
|
||||
lightColors: theme.lightColors as any,
|
||||
darkColors: theme.darkColors as any,
|
||||
baseVariant: theme.baseVariant || undefined,
|
||||
downloadCount: theme.downloadCount,
|
||||
averageRating: theme.ratingCount > 0 ? theme.ratingSum / theme.ratingCount : 0,
|
||||
ratingCount: theme.ratingCount,
|
||||
status: theme.status,
|
||||
isFeatured: theme.isFeatured,
|
||||
tags: (theme.tags as string[]) || [],
|
||||
createdAt: theme.createdAt,
|
||||
publishedAt: theme.publishedAt || undefined,
|
||||
isFavorited: userFavorites.has(theme.id),
|
||||
isDownloaded: userDownloads.has(theme.id),
|
||||
userRating: userRatingsMap.get(theme.id),
|
||||
}));
|
||||
|
||||
return {
|
||||
themes: responseThemes,
|
||||
total: Number(count),
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(Number(count) / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific community theme
|
||||
*/
|
||||
async getCommunityTheme(themeId: string, userId?: string): Promise<CommunityThemeResponseDto> {
|
||||
const [result] = await this.getDb()
|
||||
.select({
|
||||
theme: communityThemes,
|
||||
authorName: users.name,
|
||||
})
|
||||
.from(communityThemes)
|
||||
.leftJoin(users, eq(communityThemes.authorId, users.id))
|
||||
.where(eq(communityThemes.id, themeId));
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
const { theme, authorName } = result;
|
||||
|
||||
// Get user-specific data if authenticated
|
||||
let isFavorited = false;
|
||||
let isDownloaded = false;
|
||||
let userRating: number | undefined;
|
||||
|
||||
if (userId) {
|
||||
const [favorite] = await this.getDb()
|
||||
.select()
|
||||
.from(userThemeFavorites)
|
||||
.where(
|
||||
and(
|
||||
eq(userThemeFavorites.userId, userId),
|
||||
eq(userThemeFavorites.communityThemeId, themeId)
|
||||
)
|
||||
);
|
||||
isFavorited = !!favorite;
|
||||
|
||||
const [download] = await this.getDb()
|
||||
.select()
|
||||
.from(userThemeDownloads)
|
||||
.where(
|
||||
and(
|
||||
eq(userThemeDownloads.userId, userId),
|
||||
eq(userThemeDownloads.communityThemeId, themeId)
|
||||
)
|
||||
);
|
||||
isDownloaded = !!download;
|
||||
|
||||
const [rating] = await this.getDb()
|
||||
.select()
|
||||
.from(themeRatings)
|
||||
.where(and(eq(themeRatings.userId, userId), eq(themeRatings.communityThemeId, themeId)));
|
||||
userRating = rating?.rating;
|
||||
}
|
||||
|
||||
return {
|
||||
id: theme.id,
|
||||
authorId: theme.authorId ?? undefined,
|
||||
authorName: authorName || undefined,
|
||||
name: theme.name,
|
||||
description: theme.description || undefined,
|
||||
emoji: theme.emoji || '🎨',
|
||||
icon: theme.icon || 'palette',
|
||||
lightColors: theme.lightColors as any,
|
||||
darkColors: theme.darkColors as any,
|
||||
baseVariant: theme.baseVariant || undefined,
|
||||
downloadCount: theme.downloadCount,
|
||||
averageRating: theme.ratingCount > 0 ? theme.ratingSum / theme.ratingCount : 0,
|
||||
ratingCount: theme.ratingCount,
|
||||
status: theme.status,
|
||||
isFeatured: theme.isFeatured,
|
||||
tags: (theme.tags as string[]) || [],
|
||||
createdAt: theme.createdAt,
|
||||
publishedAt: theme.publishedAt || undefined,
|
||||
isFavorited,
|
||||
isDownloaded,
|
||||
userRating,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download/install a community theme
|
||||
*/
|
||||
async downloadTheme(userId: string, themeId: string) {
|
||||
const theme = await this.getCommunityTheme(themeId);
|
||||
|
||||
if (theme.status !== 'approved' && theme.status !== 'featured') {
|
||||
throw new ForbiddenException('Theme is not available for download');
|
||||
}
|
||||
|
||||
// Check if already downloaded
|
||||
const [existing] = await this.getDb()
|
||||
.select()
|
||||
.from(userThemeDownloads)
|
||||
.where(
|
||||
and(eq(userThemeDownloads.userId, userId), eq(userThemeDownloads.communityThemeId, themeId))
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
// Record download
|
||||
await this.getDb().insert(userThemeDownloads).values({
|
||||
userId,
|
||||
communityThemeId: themeId,
|
||||
});
|
||||
|
||||
// Increment download count
|
||||
await this.getDb()
|
||||
.update(communityThemes)
|
||||
.set({
|
||||
downloadCount: sql`${communityThemes.downloadCount} + 1`,
|
||||
})
|
||||
.where(eq(communityThemes.id, themeId));
|
||||
}
|
||||
|
||||
// Return the theme data for the user to apply
|
||||
return theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a community theme
|
||||
*/
|
||||
async rateTheme(userId: string, themeId: string, rating: number) {
|
||||
const theme = await this.getCommunityTheme(themeId);
|
||||
|
||||
// Check for existing rating
|
||||
const [existingRating] = await this.getDb()
|
||||
.select()
|
||||
.from(themeRatings)
|
||||
.where(and(eq(themeRatings.userId, userId), eq(themeRatings.communityThemeId, themeId)));
|
||||
|
||||
if (existingRating) {
|
||||
// Update existing rating
|
||||
const ratingDiff = rating - existingRating.rating;
|
||||
|
||||
await this.getDb()
|
||||
.update(themeRatings)
|
||||
.set({ rating, updatedAt: new Date() })
|
||||
.where(eq(themeRatings.id, existingRating.id));
|
||||
|
||||
await this.getDb()
|
||||
.update(communityThemes)
|
||||
.set({
|
||||
ratingSum: sql`${communityThemes.ratingSum} + ${ratingDiff}`,
|
||||
})
|
||||
.where(eq(communityThemes.id, themeId));
|
||||
} else {
|
||||
// Create new rating
|
||||
await this.getDb().insert(themeRatings).values({
|
||||
userId,
|
||||
communityThemeId: themeId,
|
||||
rating,
|
||||
});
|
||||
|
||||
await this.getDb()
|
||||
.update(communityThemes)
|
||||
.set({
|
||||
ratingSum: sql`${communityThemes.ratingSum} + ${rating}`,
|
||||
ratingCount: sql`${communityThemes.ratingCount} + 1`,
|
||||
})
|
||||
.where(eq(communityThemes.id, themeId));
|
||||
}
|
||||
|
||||
// Get updated theme to return new stats
|
||||
const [updatedTheme] = await this.getDb()
|
||||
.select()
|
||||
.from(communityThemes)
|
||||
.where(eq(communityThemes.id, themeId));
|
||||
|
||||
return {
|
||||
averageRating:
|
||||
updatedTheme.ratingCount > 0 ? updatedTheme.ratingSum / updatedTheme.ratingCount : 0,
|
||||
ratingCount: updatedTheme.ratingCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status for a community theme
|
||||
*/
|
||||
async toggleFavorite(userId: string, themeId: string) {
|
||||
// Verify theme exists
|
||||
await this.getCommunityTheme(themeId);
|
||||
|
||||
const [existing] = await this.getDb()
|
||||
.select()
|
||||
.from(userThemeFavorites)
|
||||
.where(
|
||||
and(eq(userThemeFavorites.userId, userId), eq(userThemeFavorites.communityThemeId, themeId))
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Remove favorite
|
||||
await this.getDb().delete(userThemeFavorites).where(eq(userThemeFavorites.id, existing.id));
|
||||
return { isFavorited: false };
|
||||
} else {
|
||||
// Add favorite
|
||||
await this.getDb().insert(userThemeFavorites).values({
|
||||
userId,
|
||||
communityThemeId: themeId,
|
||||
});
|
||||
return { isFavorited: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's favorite themes
|
||||
*/
|
||||
async getFavorites(userId: string) {
|
||||
const favorites = await this.getDb()
|
||||
.select({
|
||||
theme: communityThemes,
|
||||
authorName: users.name,
|
||||
})
|
||||
.from(userThemeFavorites)
|
||||
.innerJoin(communityThemes, eq(userThemeFavorites.communityThemeId, communityThemes.id))
|
||||
.leftJoin(users, eq(communityThemes.authorId, users.id))
|
||||
.where(eq(userThemeFavorites.userId, userId))
|
||||
.orderBy(desc(userThemeFavorites.createdAt));
|
||||
|
||||
return favorites.map(({ theme, authorName }) => ({
|
||||
id: theme.id,
|
||||
authorId: theme.authorId,
|
||||
authorName: authorName || undefined,
|
||||
name: theme.name,
|
||||
description: theme.description || undefined,
|
||||
emoji: theme.emoji || '🎨',
|
||||
icon: theme.icon || 'palette',
|
||||
lightColors: theme.lightColors,
|
||||
darkColors: theme.darkColors,
|
||||
baseVariant: theme.baseVariant || undefined,
|
||||
downloadCount: theme.downloadCount,
|
||||
averageRating: theme.ratingCount > 0 ? theme.ratingSum / theme.ratingCount : 0,
|
||||
ratingCount: theme.ratingCount,
|
||||
status: theme.status,
|
||||
isFeatured: theme.isFeatured,
|
||||
tags: (theme.tags as string[]) || [],
|
||||
createdAt: theme.createdAt,
|
||||
publishedAt: theme.publishedAt || undefined,
|
||||
isFavorited: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's downloaded themes
|
||||
*/
|
||||
async getDownloadedThemes(userId: string) {
|
||||
const downloads = await this.getDb()
|
||||
.select({
|
||||
theme: communityThemes,
|
||||
authorName: users.name,
|
||||
})
|
||||
.from(userThemeDownloads)
|
||||
.innerJoin(communityThemes, eq(userThemeDownloads.communityThemeId, communityThemes.id))
|
||||
.leftJoin(users, eq(communityThemes.authorId, users.id))
|
||||
.where(eq(userThemeDownloads.userId, userId))
|
||||
.orderBy(desc(userThemeDownloads.createdAt));
|
||||
|
||||
return downloads.map(({ theme, authorName }) => ({
|
||||
id: theme.id,
|
||||
authorId: theme.authorId,
|
||||
authorName: authorName || undefined,
|
||||
name: theme.name,
|
||||
description: theme.description || undefined,
|
||||
emoji: theme.emoji || '🎨',
|
||||
icon: theme.icon || 'palette',
|
||||
lightColors: theme.lightColors,
|
||||
darkColors: theme.darkColors,
|
||||
baseVariant: theme.baseVariant || undefined,
|
||||
downloadCount: theme.downloadCount,
|
||||
averageRating: theme.ratingCount > 0 ? theme.ratingSum / theme.ratingCount : 0,
|
||||
ratingCount: theme.ratingCount,
|
||||
status: theme.status,
|
||||
isFeatured: theme.isFeatured,
|
||||
tags: (theme.tags as string[]) || [],
|
||||
createdAt: theme.createdAt,
|
||||
publishedAt: theme.publishedAt || undefined,
|
||||
isDownloaded: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue