feat: Implement AI response feedback UI and complete high-priority features
Frontend Features: - Add MessageFeedback component with thumbs up/down buttons - Positive feedback submits immediately with success toast - Negative feedback opens dialog for optional text input - Integrate feedback buttons on all AI assistant messages - Add success Snackbar confirmation message - Translation keys added to ai.json (feedback section) Backend Features: - Add POST /api/v1/ai/feedback endpoint - Create FeedbackDto with conversation ID validation - Implement submitFeedback service method - Store feedback in conversation metadata with timestamps - Add audit logging for feedback submissions - Fix conversationId regex validation to support nanoid format Legal & Compliance: - Implement complete EULA acceptance flow with modal - Create reusable legal content components (Terms, Privacy, EULA) - Add LegalDocumentViewer for nested modal viewing - Cookie Consent Banner with GDPR compliance - Legal pages with AppShell navigation - EULA acceptance tracking in user entity Branding Updates: - Rebrand from "Maternal App" to "ParentFlow" - Update all icons (72px to 512px) from high-res source - PWA manifest updated with ParentFlow branding - Contact email: hello@parentflow.com - Address: Serbota 3, Bucharest, Romania Bug Fixes: - Fix chat endpoint validation (support nanoid conversation IDs) - Fix EULA acceptance API call (use apiClient vs hardcoded localhost) - Fix icon loading errors with proper PNG generation Documentation: - Mark 11 high-priority features as complete in REMAINING_FEATURES.md - Update feature statistics: 73/139 complete (53%) - All high-priority features now complete! 🎉 Files Changed: Frontend: 21 files (components, pages, locales, icons) Backend: 6 files (controller, service, DTOs, migrations) Docs: 1 file (REMAINING_FEATURES.md) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,17 @@ export class User {
|
||||
@Column({ name: 'parental_email', length: 255, nullable: true })
|
||||
parentalEmail?: string | null;
|
||||
|
||||
// EULA/Legal acceptance tracking
|
||||
@Column({
|
||||
name: 'eula_accepted_at',
|
||||
type: 'timestamp without time zone',
|
||||
nullable: true,
|
||||
})
|
||||
eulaAcceptedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'eula_version', length: 20, nullable: true })
|
||||
eulaVersion?: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
preferences?: {
|
||||
notifications?: boolean;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration V008: Add EULA acceptance tracking to users table
|
||||
-- Created: 2025-10-04
|
||||
-- Description: Adds eulaAcceptedAt and eulaVersion fields to track when users accept legal agreements
|
||||
|
||||
-- Add EULA acceptance timestamp
|
||||
ALTER TABLE users
|
||||
ADD COLUMN eula_accepted_at TIMESTAMP WITHOUT TIME ZONE NULL;
|
||||
|
||||
-- Add EULA version tracking (e.g., "1.0", "2024-10-04")
|
||||
ALTER TABLE users
|
||||
ADD COLUMN eula_version VARCHAR(20) NULL;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN users.eula_accepted_at IS 'Timestamp when user accepted the EULA';
|
||||
COMMENT ON COLUMN users.eula_version IS 'Version of EULA that was accepted (e.g., "1.0", "2024-10-04")';
|
||||
|
||||
-- Create index for quickly finding users who haven't accepted EULA
|
||||
CREATE INDEX idx_users_eula_acceptance ON users(eula_accepted_at) WHERE eula_accepted_at IS NULL;
|
||||
|
||||
COMMENT ON INDEX idx_users_eula_acceptance IS 'Find users who have not yet accepted the EULA (partial index)';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Response } from 'express';
|
||||
import { AIService } from './ai.service';
|
||||
import { ChatMessageDto } from './dto/chat-message.dto';
|
||||
import { FeedbackDto } from './dto/feedback.dto';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
|
||||
@Controller('api/v1/ai')
|
||||
@@ -148,6 +149,23 @@ export class AIController {
|
||||
};
|
||||
}
|
||||
|
||||
@Public() // Public for testing
|
||||
@Post('feedback')
|
||||
async submitFeedback(@Req() req: any, @Body() feedbackDto: FeedbackDto) {
|
||||
const userId = req.user?.userId || 'test_user_123';
|
||||
await this.aiService.submitFeedback(
|
||||
userId,
|
||||
feedbackDto.conversationId,
|
||||
feedbackDto.messageId,
|
||||
feedbackDto.feedbackType,
|
||||
feedbackDto.feedbackText,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Feedback submitted successfully',
|
||||
};
|
||||
}
|
||||
|
||||
// Embeddings testing endpoints
|
||||
@Public() // Public for testing
|
||||
@Post('test/embeddings/generate')
|
||||
|
||||
@@ -927,6 +927,63 @@ export class AIService {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for an AI message
|
||||
*/
|
||||
async submitFeedback(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
feedbackType: 'positive' | 'negative',
|
||||
feedbackText?: string,
|
||||
): Promise<void> {
|
||||
// Validate conversation belongs to user
|
||||
const conversation = await this.conversationRepository.findOne({
|
||||
where: { id: conversationId, userId },
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
throw new BadRequestException('Conversation not found');
|
||||
}
|
||||
|
||||
// Initialize feedback array in metadata if it doesn't exist
|
||||
if (!conversation.metadata.feedback) {
|
||||
conversation.metadata.feedback = [];
|
||||
}
|
||||
|
||||
// Add feedback entry
|
||||
const feedbackEntry = {
|
||||
messageId,
|
||||
feedbackType,
|
||||
feedbackText: feedbackText || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
conversation.metadata.feedback.push(feedbackEntry);
|
||||
|
||||
// Save conversation
|
||||
await this.conversationRepository.save(conversation);
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log({
|
||||
userId,
|
||||
action: 'CREATE' as any,
|
||||
entityType: 'FEEDBACK' as any,
|
||||
entityId: conversationId,
|
||||
changes: {
|
||||
after: {
|
||||
messageId,
|
||||
feedbackType,
|
||||
hasFeedbackText: !!feedbackText,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Feedback submitted: ${feedbackType} for message ${messageId} in conversation ${conversationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current AI provider status
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ export class ChatMessageDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^conv_[a-z0-9]{16}$/, {
|
||||
@Matches(/^conv_[a-zA-Z0-9_-]{8,}$/, {
|
||||
message: 'Invalid conversation ID format',
|
||||
})
|
||||
conversationId?: string;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IsString, IsOptional, IsIn, IsNotEmpty, MaxLength, Matches } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class FeedbackDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^conv_[a-z0-9A-Z_-]{8,}$/, {
|
||||
message: 'Invalid conversation ID format',
|
||||
})
|
||||
conversationId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
messageId: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['positive', 'negative'], {
|
||||
message: 'Feedback type must be either "positive" or "negative"',
|
||||
})
|
||||
feedbackType: 'positive' | 'negative';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000, { message: 'Feedback text cannot exceed 1000 characters' })
|
||||
@Transform(({ value }) => value?.trim())
|
||||
feedbackText?: string;
|
||||
}
|
||||
@@ -480,4 +480,18 @@ export class AuthController {
|
||||
hasCredentials,
|
||||
};
|
||||
}
|
||||
|
||||
// EULA Acceptance Endpoint
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('eula/accept')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async acceptEULA(
|
||||
@CurrentUser() user: any,
|
||||
@Body() body: { version?: string },
|
||||
) {
|
||||
return await this.authService.acceptEULA(
|
||||
user.userId,
|
||||
body.version || '2025-10-04',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +371,8 @@ export class AuthService {
|
||||
emailVerified: user.emailVerified,
|
||||
preferences: user.preferences,
|
||||
families,
|
||||
eulaAcceptedAt: user.eulaAcceptedAt,
|
||||
eulaVersion: user.eulaVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -596,4 +598,43 @@ export class AuthService {
|
||||
|
||||
return age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept EULA for user
|
||||
*/
|
||||
async acceptEULA(userId: string, version: string = '2025-10-04'): Promise<any> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Update EULA acceptance
|
||||
user.eulaAcceptedAt = new Date();
|
||||
user.eulaVersion = version;
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// Audit log for EULA acceptance
|
||||
await this.auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.CONSENT_GRANTED,
|
||||
entityType: EntityType.USER,
|
||||
entityId: user.id,
|
||||
changes: {
|
||||
after: { eulaAcceptedAt: user.eulaAcceptedAt, eulaVersion: version },
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`User ${userId} accepted EULA version ${version}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'EULA accepted successfully',
|
||||
eulaAcceptedAt: user.eulaAcceptedAt,
|
||||
eulaVersion: user.eulaVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user