feat(feedback): add centralized feedback system with AI-generated titles

- Add shared-feedback-types package with TypeScript types
- Add shared-feedback-service package with factory function
- Add shared-feedback-ui package with Svelte 5 components
- Add feedback module to mana-core-auth backend
- Add AI service using Gemini 2.0 Flash for title/category generation
- Add database schema and migration for feedback tables
- Integrate feedback page into Chat web app
- Add CORS support for X-App-Id header
- Add COMMANDS.md documentation for all dev commands

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 22:46:37 +01:00
parent 05fe8ca5b6
commit 819e4c9a2f
41 changed files with 4290 additions and 338 deletions

View file

@ -21,6 +21,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { AiService } from './ai.service';
@Global()
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View file

@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
export interface FeedbackAnalysis {
title: string;
category: 'bug' | 'feature' | 'improvement' | 'question' | 'other';
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private genAI: GoogleGenerativeAI | null = null;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('ai.geminiApiKey');
if (apiKey) {
this.genAI = new GoogleGenerativeAI(apiKey);
} else {
this.logger.warn('GOOGLE_GENAI_API_KEY not configured - AI features disabled');
}
}
async analyzeFeedback(feedbackText: string): Promise<FeedbackAnalysis> {
// Fallback if AI not available
if (!this.genAI) {
return this.fallbackAnalysis(feedbackText);
}
try {
const model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
const prompt = `Analysiere dieses User-Feedback und generiere:
1. Einen kurzen, prägnanten deutschen Titel (max 60 Zeichen) der den Kern des Feedbacks zusammenfasst
2. Eine passende Kategorie aus: bug, feature, improvement, question, other
Feedback: "${feedbackText}"
Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein anderer Text):
{"title": "...", "category": "..."}`;
const result = await model.generateContent(prompt);
const response = result.response.text().trim();
// Parse JSON response - handle potential markdown code blocks
let jsonStr = response;
if (response.includes('```')) {
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
jsonStr = match[1].trim();
}
}
const parsed = JSON.parse(jsonStr) as FeedbackAnalysis;
// Validate category
const validCategories = ['bug', 'feature', 'improvement', 'question', 'other'];
if (!validCategories.includes(parsed.category)) {
parsed.category = 'other';
}
// Ensure title is not too long
if (parsed.title.length > 60) {
parsed.title = parsed.title.substring(0, 57) + '...';
}
this.logger.debug(`AI analyzed feedback: ${JSON.stringify(parsed)}`);
return parsed;
} catch (error) {
this.logger.error(`AI analysis failed: ${error}`);
return this.fallbackAnalysis(feedbackText);
}
}
private fallbackAnalysis(feedbackText: string): FeedbackAnalysis {
// Simple fallback: use first 60 chars as title, default category
const title =
feedbackText.length > 60 ? feedbackText.substring(0, 57) + '...' : feedbackText;
// Simple keyword-based category detection
const lowerText = feedbackText.toLowerCase();
let category: FeedbackAnalysis['category'] = 'feature';
if (
lowerText.includes('bug') ||
lowerText.includes('fehler') ||
lowerText.includes('kaputt') ||
lowerText.includes('funktioniert nicht')
) {
category = 'bug';
} else if (lowerText.includes('?') || lowerText.includes('frage') || lowerText.includes('wie')) {
category = 'question';
} else if (
lowerText.includes('besser') ||
lowerText.includes('verbessern') ||
lowerText.includes('optimieren')
) {
category = 'improvement';
}
return { title, category };
}
}

View file

@ -0,0 +1,2 @@
export * from './ai.module';
export * from './ai.service';

View file

@ -5,6 +5,8 @@ import { APP_FILTER } from '@nestjs/core';
import configuration from './config/configuration';
import { AuthModule } from './auth/auth.module';
import { CreditsModule } from './credits/credits.module';
import { FeedbackModule } from './feedback/feedback.module';
import { AiModule } from './ai/ai.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
@Module({
@ -19,8 +21,10 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
limit: 100, // 100 requests per minute
},
]),
AiModule,
AuthModule,
CreditsModule,
FeedbackModule,
],
providers: [
{

View file

@ -44,4 +44,8 @@ export default () => ({
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
},
ai: {
geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '',
},
});

View file

@ -0,0 +1,39 @@
CREATE SCHEMA "feedback";
--> statement-breakpoint
CREATE TYPE "public"."feedback_category" AS ENUM('bug', 'feature', 'improvement', 'question', 'other');--> statement-breakpoint
CREATE TYPE "public"."feedback_status" AS ENUM('submitted', 'under_review', 'planned', 'in_progress', 'completed', 'declined');--> statement-breakpoint
CREATE TABLE "feedback"."feedback_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"feedback_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feedback"."user_feedback" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"app_id" text NOT NULL,
"title" text,
"feedback_text" text NOT NULL,
"category" "feedback_category" DEFAULT 'feature' NOT NULL,
"status" "feedback_status" DEFAULT 'submitted' NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"admin_response" text,
"vote_count" integer DEFAULT 0 NOT NULL,
"device_info" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"published_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_feedback_id_user_feedback_id_fk" FOREIGN KEY ("feedback_id") REFERENCES "feedback"."user_feedback"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feedback"."user_feedback" ADD CONSTRAINT "user_feedback_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "feedback_vote_unique" ON "feedback"."feedback_votes" USING btree ("feedback_id","user_id");--> statement-breakpoint
CREATE INDEX "feedback_votes_feedback_idx" ON "feedback"."feedback_votes" USING btree ("feedback_id");--> statement-breakpoint
CREATE INDEX "feedback_user_idx" ON "feedback"."user_feedback" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "feedback_app_idx" ON "feedback"."user_feedback" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "feedback_public_idx" ON "feedback"."user_feedback" USING btree ("is_public");--> statement-breakpoint
CREATE INDEX "feedback_status_idx" ON "feedback"."user_feedback" USING btree ("status");--> statement-breakpoint
CREATE INDEX "feedback_created_at_idx" ON "feedback"."user_feedback" USING btree ("created_at");

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764448681401,
"tag": "0001_zippy_ma_gnuci",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,94 @@
import {
pgSchema,
uuid,
text,
timestamp,
boolean,
jsonb,
integer,
index,
pgEnum,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
export const feedbackSchema = pgSchema('feedback');
// Category enum
export const feedbackCategoryEnum = pgEnum('feedback_category', [
'bug',
'feature',
'improvement',
'question',
'other',
]);
// Status enum
export const feedbackStatusEnum = pgEnum('feedback_status', [
'submitted',
'under_review',
'planned',
'in_progress',
'completed',
'declined',
]);
// User feedback table
export const userFeedback = feedbackSchema.table(
'user_feedback',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
appId: text('app_id').notNull(), // 'chat', 'picture', 'zitare', etc.
// Content
title: text('title'),
feedbackText: text('feedback_text').notNull(),
category: feedbackCategoryEnum('category').default('feature').notNull(),
// Status & Publishing
status: feedbackStatusEnum('status').default('submitted').notNull(),
isPublic: boolean('is_public').default(false).notNull(),
adminResponse: text('admin_response'),
// Voting (denormalized for performance)
voteCount: integer('vote_count').default(0).notNull(),
// Metadata
deviceInfo: jsonb('device_info'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => ({
userIdx: index('feedback_user_idx').on(table.userId),
appIdx: index('feedback_app_idx').on(table.appId),
publicIdx: index('feedback_public_idx').on(table.isPublic),
statusIdx: index('feedback_status_idx').on(table.status),
createdAtIdx: index('feedback_created_at_idx').on(table.createdAt),
})
);
// Feedback votes table
export const feedbackVotes = feedbackSchema.table(
'feedback_votes',
{
id: uuid('id').primaryKey().defaultRandom(),
feedbackId: uuid('feedback_id')
.references(() => userFeedback.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
uniqueVote: uniqueIndex('feedback_vote_unique').on(table.feedbackId, table.userId),
feedbackIdx: index('feedback_votes_feedback_idx').on(table.feedbackId),
})
);

View file

@ -1,2 +1,3 @@
export * from './auth.schema';
export * from './credits.schema';
export * from './feedback.schema';

View file

@ -0,0 +1,21 @@
import { IsString, IsOptional, MaxLength, MinLength, IsEnum, IsObject } from 'class-validator';
export class CreateFeedbackDto {
@IsString()
@IsOptional()
@MaxLength(100)
title?: string;
@IsString()
@MinLength(10, { message: 'Feedback must be at least 10 characters long' })
@MaxLength(2000, { message: 'Feedback must be at most 2000 characters long' })
feedbackText: string;
@IsEnum(['bug', 'feature', 'improvement', 'question', 'other'])
@IsOptional()
category?: 'bug' | 'feature' | 'improvement' | 'question' | 'other';
@IsObject()
@IsOptional()
deviceInfo?: Record<string, unknown>;
}

View file

@ -0,0 +1,33 @@
import { IsString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class FeedbackQueryDto {
@IsString()
@IsOptional()
appId?: string;
@IsEnum(['submitted', 'under_review', 'planned', 'in_progress', 'completed', 'declined'])
@IsOptional()
status?: string;
@IsEnum(['bug', 'feature', 'improvement', 'question', 'other'])
@IsOptional()
category?: string;
@IsEnum(['votes', 'recent'])
@IsOptional()
sort?: 'votes' | 'recent' = 'votes';
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
@Max(50)
@IsOptional()
limit?: number = 20;
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(0)
@IsOptional()
offset?: number = 0;
}

View file

@ -0,0 +1,2 @@
export { CreateFeedbackDto } from './create-feedback.dto';
export { FeedbackQueryDto } from './feedback-query.dto';

View file

@ -0,0 +1,54 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Headers,
} from '@nestjs/common';
import { FeedbackService } from './feedback.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { CreateFeedbackDto, FeedbackQueryDto } from './dto';
@Controller('feedback')
@UseGuards(JwtAuthGuard)
export class FeedbackController {
constructor(private readonly feedbackService: FeedbackService) {}
@Post()
async createFeedback(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateFeedbackDto,
@Headers('x-app-id') appIdHeader?: string
) {
const appId = appIdHeader || 'unknown';
return this.feedbackService.createFeedback(user.userId, appId, dto);
}
@Get('public')
async getPublicFeedback(@CurrentUser() user: CurrentUserData, @Query() query: FeedbackQueryDto) {
return this.feedbackService.getPublicFeedback(user.userId, query);
}
@Get('my')
async getMyFeedback(
@CurrentUser() user: CurrentUserData,
@Query('appId') appId?: string
) {
return this.feedbackService.getMyFeedback(user.userId, appId);
}
@Post(':id/vote')
async vote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
return this.feedbackService.vote(user.userId, feedbackId);
}
@Delete(':id/vote')
async unvote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
return this.feedbackService.unvote(user.userId, feedbackId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FeedbackController } from './feedback.controller';
import { FeedbackService } from './feedback.service';
@Module({
controllers: [FeedbackController],
providers: [FeedbackService],
exports: [FeedbackService],
})
export class FeedbackModule {}

View file

@ -0,0 +1,277 @@
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, desc, sql, count } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { userFeedback, feedbackVotes } from '../db/schema';
import { CreateFeedbackDto, FeedbackQueryDto } from './dto';
import { AiService } from '../ai/ai.service';
@Injectable()
export class FeedbackService {
private readonly logger = new Logger(FeedbackService.name);
constructor(
private configService: ConfigService,
private aiService: AiService
) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
async createFeedback(userId: string, appId: string, dto: CreateFeedbackDto) {
const db = this.getDb();
// Use AI to generate title and category if not provided
let title = dto.title;
let category = dto.category;
if (!title || !category) {
this.logger.debug('Analyzing feedback with AI...');
const analysis = await this.aiService.analyzeFeedback(dto.feedbackText);
if (!title) {
title = analysis.title;
}
if (!category) {
category = analysis.category;
}
this.logger.debug(`AI generated: title="${title}", category="${category}"`);
}
const [feedback] = await db
.insert(userFeedback)
.values({
userId,
appId,
title,
feedbackText: dto.feedbackText,
category: category || 'feature',
deviceInfo: dto.deviceInfo,
})
.returning();
return {
success: true,
feedback: this.mapFeedback(feedback, false),
};
}
async getPublicFeedback(userId: string, query: FeedbackQueryDto) {
const db = this.getDb();
const { appId, status, category, sort = 'votes', limit = 20, offset = 0 } = query;
// Build conditions
const conditions = [eq(userFeedback.isPublic, true)];
if (appId) {
conditions.push(eq(userFeedback.appId, appId));
}
if (status) {
conditions.push(eq(userFeedback.status, status as any));
}
if (category) {
conditions.push(eq(userFeedback.category, category as any));
}
// Get feedback items
const feedbackItems = await db
.select()
.from(userFeedback)
.where(and(...conditions))
.orderBy(sort === 'votes' ? desc(userFeedback.voteCount) : desc(userFeedback.createdAt))
.limit(limit)
.offset(offset);
// Get total count
const [{ total }] = await db
.select({ total: count() })
.from(userFeedback)
.where(and(...conditions));
// Get user's votes
const feedbackIds = feedbackItems.map((f) => f.id);
const userVotes =
feedbackIds.length > 0
? await db
.select({ feedbackId: feedbackVotes.feedbackId })
.from(feedbackVotes)
.where(
and(
eq(feedbackVotes.userId, userId),
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
)
)
: [];
const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
return {
success: true,
items: feedbackItems.map((f) => this.mapFeedback(f, votedFeedbackIds.has(f.id))),
total,
};
}
async getMyFeedback(userId: string, appId?: string) {
const db = this.getDb();
const conditions = [eq(userFeedback.userId, userId)];
if (appId) {
conditions.push(eq(userFeedback.appId, appId));
}
const feedbackItems = await db
.select()
.from(userFeedback)
.where(and(...conditions))
.orderBy(desc(userFeedback.createdAt));
// Get user's votes on their own feedback (for consistency)
const feedbackIds = feedbackItems.map((f) => f.id);
const userVotes =
feedbackIds.length > 0
? await db
.select({ feedbackId: feedbackVotes.feedbackId })
.from(feedbackVotes)
.where(
and(
eq(feedbackVotes.userId, userId),
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
)
)
: [];
const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
return {
success: true,
items: feedbackItems.map((f) => this.mapFeedback(f, votedFeedbackIds.has(f.id))),
total: feedbackItems.length,
};
}
async vote(userId: string, feedbackId: string) {
const db = this.getDb();
// Check if feedback exists and is public
const [feedback] = await db
.select()
.from(userFeedback)
.where(eq(userFeedback.id, feedbackId))
.limit(1);
if (!feedback) {
throw new NotFoundException('Feedback not found');
}
if (!feedback.isPublic) {
throw new NotFoundException('Feedback not found or not public');
}
// Check if user already voted
const [existingVote] = await db
.select()
.from(feedbackVotes)
.where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId)))
.limit(1);
if (existingVote) {
throw new ConflictException('Already voted');
}
// Add vote
await db.insert(feedbackVotes).values({
feedbackId,
userId,
});
// Increment vote count
const [updated] = await db
.update(userFeedback)
.set({
voteCount: sql`${userFeedback.voteCount} + 1`,
updatedAt: new Date(),
})
.where(eq(userFeedback.id, feedbackId))
.returning();
return {
success: true,
newVoteCount: updated.voteCount,
userHasVoted: true,
};
}
async unvote(userId: string, feedbackId: string) {
const db = this.getDb();
// Check if feedback exists
const [feedback] = await db
.select()
.from(userFeedback)
.where(eq(userFeedback.id, feedbackId))
.limit(1);
if (!feedback) {
throw new NotFoundException('Feedback not found');
}
// Check if user has voted
const [existingVote] = await db
.select()
.from(feedbackVotes)
.where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId)))
.limit(1);
if (!existingVote) {
throw new NotFoundException('Vote not found');
}
// Remove vote
await db
.delete(feedbackVotes)
.where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId)));
// Decrement vote count
const [updated] = await db
.update(userFeedback)
.set({
voteCount: sql`GREATEST(${userFeedback.voteCount} - 1, 0)`,
updatedAt: new Date(),
})
.where(eq(userFeedback.id, feedbackId))
.returning();
return {
success: true,
newVoteCount: updated.voteCount,
userHasVoted: false,
};
}
private mapFeedback(
feedback: typeof userFeedback.$inferSelect,
userHasVoted: boolean
): Record<string, unknown> {
return {
id: feedback.id,
userId: feedback.userId,
appId: feedback.appId,
title: feedback.title,
feedbackText: feedback.feedbackText,
category: feedback.category,
status: feedback.status,
isPublic: feedback.isPublic,
adminResponse: feedback.adminResponse,
voteCount: feedback.voteCount,
userHasVoted,
deviceInfo: feedback.deviceInfo,
createdAt: feedback.createdAt.toISOString(),
updatedAt: feedback.updatedAt.toISOString(),
publishedAt: feedback.publishedAt?.toISOString(),
completedAt: feedback.completedAt?.toISOString(),
};
}
}

View file

@ -20,7 +20,7 @@ async function bootstrap() {
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-App-Id'],
});
// Global validation pipe