mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 12:49:40 +02:00
281 lines
7 KiB
TypeScript
281 lines
7 KiB
TypeScript
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 } from './dto';
|
|
import type { 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 | null, 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 (only if authenticated)
|
|
let votedFeedbackIds = new Set<string>();
|
|
if (userId) {
|
|
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})`
|
|
)
|
|
)
|
|
: [];
|
|
|
|
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(),
|
|
};
|
|
}
|
|
}
|