mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
05fe8ca5b6
commit
819e4c9a2f
41 changed files with 4290 additions and 338 deletions
|
|
@ -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",
|
||||
|
|
|
|||
9
services/mana-core-auth/src/ai/ai.module.ts
Normal file
9
services/mana-core-auth/src/ai/ai.module.ts
Normal 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 {}
|
||||
103
services/mana-core-auth/src/ai/ai.service.ts
Normal file
103
services/mana-core-auth/src/ai/ai.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
2
services/mana-core-auth/src/ai/index.ts
Normal file
2
services/mana-core-auth/src/ai/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './ai.module';
|
||||
export * from './ai.service';
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
1600
services/mana-core-auth/src/db/migrations/meta/0001_snapshot.json
Normal file
1600
services/mana-core-auth/src/db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
94
services/mana-core-auth/src/db/schema/feedback.schema.ts
Normal file
94
services/mana-core-auth/src/db/schema/feedback.schema.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './auth.schema';
|
||||
export * from './credits.schema';
|
||||
export * from './feedback.schema';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
2
services/mana-core-auth/src/feedback/dto/index.ts
Normal file
2
services/mana-core-auth/src/feedback/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { CreateFeedbackDto } from './create-feedback.dto';
|
||||
export { FeedbackQueryDto } from './feedback-query.dto';
|
||||
54
services/mana-core-auth/src/feedback/feedback.controller.ts
Normal file
54
services/mana-core-auth/src/feedback/feedback.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/feedback/feedback.module.ts
Normal file
10
services/mana-core-auth/src/feedback/feedback.module.ts
Normal 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 {}
|
||||
277
services/mana-core-auth/src/feedback/feedback.service.ts
Normal file
277
services/mana-core-auth/src/feedback/feedback.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue