Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"react-hot-toast": "^2.5.2",
"react-resizable-panels": "^2.1.3",
"recharts": "^2.12.7",
"sentiment": "^5.0.2",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss": "^3.3.3",
Expand All @@ -83,6 +84,7 @@
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
"@types/sentiment": "^5.0.4",
"dotenv": "^17.0.1",
"ts-node": "^10.9.2",
"tsx": "^4.20.6"
Expand Down
15 changes: 15 additions & 0 deletions scripts/add_sentiment.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Add sentiment_label to ratings

ALTER TABLE ratings
ADD COLUMN IF NOT EXISTS sentiment_label TEXT;

ALTER TABLE ratings
ADD CONSTRAINT ratings_sentiment_label_check
CHECK (
sentiment_label IS NULL OR
sentiment_label IN ('positive', 'negative', 'neutral')
);

UPDATE ratings
SET sentiment_label = 'neutral'
WHERE sentiment_label IS NULL;
41 changes: 41 additions & 0 deletions src/lib/sentiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Sentiment from 'sentiment';

export type SentimentLabel = 'positive' | 'negative' | 'neutral';

export interface SentimentResult {
label: SentimentLabel;
score: number;
}

const analyzer = new Sentiment();

/**
* Analyzes the sentiment of a review comment.
* Optionally blends the result with a numeric overall rating (60/40 weight).
*
* @param text - The review comment text
* @param overallRating - Optional numeric rating (1–5)
* @returns SentimentResult with label and normalized score (-1 to 1)
*/
export function analyzeSentiment(text: string, overallRating?: number): SentimentResult {
const { comparative } = analyzer.analyze(text || '');

let score = Math.max(-1, Math.min(1, comparative / 5));

if (typeof overallRating === 'number') {
const clampedRating = Math.max(1, Math.min(5, overallRating));
const ratingScore = (clampedRating - 3) / 2;

score = (score * 0.6) + (ratingScore * 0.4);
}

if (score > 0.1) {
return { label: 'positive', score };
}

if (score < -0.1) {
return { label: 'negative', score };
}

return { label: 'neutral', score };
}
67 changes: 34 additions & 33 deletions src/pages/api/ratings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,66 +3,70 @@ import { supabase } from '@/lib/supabase';
import { supabaseAdmin } from '@/lib/supabase-admin';
import { createFuzzyTimestamp, sanitizeContent } from '@/lib/anonymization';
import { RatingInsert } from '@/types/supabase';
import { analyzeSentiment } from '@/lib/sentiment';

// POST /api/ratings - Create a new rating
export async function POST(request: Request) {
try {
// Get session to verify authentication
const { data: { session } } = await supabase.auth.getSession();

if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}

// Get JSON data from request
const json = await request.json();
const {
const {
targetId,
targetType,
ratingMetrics,
comment,
semester,
year
} = json;

// Validate required fields
if (!targetId || !targetType || !ratingMetrics || !semester || !year) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}

// Validate target type
if (targetType !== 'course' && targetType !== 'professor') {
return NextResponse.json(
{ error: 'Invalid target type' },
{ status: 400 }
);
}

// Get the anonymous ID for the authenticated user
const { data: anonymousData, error: anonymousError } = await supabase
.from('users')
.select('anonymous_id')
.eq('auth_id', session.user.id)
.single();

if (anonymousError || !anonymousData?.anonymous_id) {
console.error('Error fetching anonymous ID:', anonymousError);
return NextResponse.json(
{ error: 'Failed to verify anonymous identity' },
{ status: 500 }
);
}

// Process the rating data
const sanitizedComment = comment ? sanitizeContent(comment) : null;
const displayDate = createFuzzyTimestamp();


// Analyze sentiment of the comment (neutral if no comment)
const sentiment = analyzeSentiment(sanitizedComment ?? '', ratingMetrics?.overall);

// Check if user has already rated this target
const { data: existingRating, error: checkError } = await supabase
.from('ratings')
Expand All @@ -71,55 +75,54 @@ export async function POST(request: Request) {
.eq('target_id', targetId)
.eq('target_type', targetType)
.single();

// If user has already rated, return error
if (existingRating) {
return NextResponse.json(
{ error: 'You have already rated this item. Please edit your existing rating instead.' },
{ status: 409 }
);
}

// Create the rating
const ratingData: RatingInsert = {
anonymous_id: anonymousData.anonymous_id,
target_id: targetId,
target_type: targetType,
rating_metrics: ratingMetrics,
comment: sanitizedComment,
sentiment_label: sentiment.label,
semester,
year,
display_date: displayDate,
helpfulness_score: 0,
is_flagged: false
};

// Use admin client to create rating (to bypass RLS if needed)
const { data, error } = await supabaseAdmin
.from('ratings')
.insert(ratingData)
.select('id')
.single();

if (error) {
console.error('Error creating rating:', error);
return NextResponse.json(
{ error: 'Failed to submit rating' },
{ status: 500 }
);
}

// Update the rating statistics view or trigger
// This would typically be handled by a database trigger


return NextResponse.json({
success: true,
data: {
id: data.id,
displayDate
displayDate,
sentimentLabel: sentiment.label,
}
});

} catch (error) {
console.error('Unexpected error in ratings API:', error);
return NextResponse.json(
Expand All @@ -139,69 +142,67 @@ export async function GET(request: Request) {
const pageSize = parseInt(searchParams.get('pageSize') || '10');
const sortBy = searchParams.get('sortBy') || 'created_at';
const sortOrder = searchParams.get('sortOrder') || 'desc';

// Validate required params
if (!targetId || !targetType) {
return NextResponse.json(
{ error: 'Missing required parameters: targetId and targetType' },
{ status: 400 }
);
}

// Validate target type
if (targetType !== 'course' && targetType !== 'professor') {
return NextResponse.json(
{ error: 'Invalid target type' },
{ status: 400 }
);
}

// Calculate pagination
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;

// Query ratings
let query = supabase
.from('ratings')
.select('id, rating_metrics, comment, semester, year, display_date, created_at, helpfulness_score')
.select('id, rating_metrics, comment, sentiment_label, semester, year, display_date, created_at, helpfulness_score')
.eq('target_id', targetId)
.eq('target_type', targetType)
.eq('is_flagged', false)
.range(from, to);

// Apply sorting
if (sortBy === 'helpfulness') {
query = query.order('helpfulness_score', { ascending: sortOrder === 'asc' });
} else if (sortBy === 'date') {
query = query.order('created_at', { ascending: sortOrder === 'asc' });
} else if (sortBy === 'rating') {
// For rating, we need to sort by the overall metric within the rating_metrics JSONB field
// This might need a different approach depending on your database
query = query.order('rating_metrics->overall', { ascending: sortOrder === 'asc' });
}

const { data: ratings, error, count } = await query;

if (error) {
console.error('Error fetching ratings:', error);
return NextResponse.json(
{ error: 'Failed to fetch ratings' },
{ status: 500 }
);
}

// Get total count for pagination
const { count: totalCount, error: countError } = await supabase
.from('ratings')
.select('*', { count: 'exact', head: true })
.eq('target_id', targetId)
.eq('target_type', targetType)
.eq('is_flagged', false);

if (countError) {
console.error('Error counting ratings:', countError);
}

return NextResponse.json({
data: ratings,
meta: {
Expand All @@ -211,12 +212,12 @@ export async function GET(request: Request) {
totalPages: Math.ceil((totalCount || 0) / pageSize)
}
});

} catch (error) {
console.error('Unexpected error in ratings API:', error);
return NextResponse.json(
{ error: 'An unexpected error occurred' },
{ status: 500 }
);
}
}
}
5 changes: 4 additions & 1 deletion src/types/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export interface Database {
approachability?: number
}
comment: string | null
sentiment_label: 'positive' | 'negative' | 'neutral' | null
semester: string
year: number
display_date: string
Expand All @@ -165,6 +166,7 @@ export interface Database {
approachability?: number
}
comment?: string | null
sentiment_label?: 'positive' | 'negative' | 'neutral' | null
semester: string
year: number
display_date: string
Expand All @@ -187,6 +189,7 @@ export interface Database {
approachability?: number
}
comment?: string | null
sentiment_label?: 'positive' | 'negative' | 'neutral' | null
semester?: string
year?: number
display_date?: string
Expand Down Expand Up @@ -362,4 +365,4 @@ export type Department = Database['public']['Tables']['departments']['Row']
export type DepartmentInsert = Database['public']['Tables']['departments']['Insert']

export type CourseStats = Database['public']['Views']['course_stats']['Row']
export type ProfessorStats = Database['public']['Views']['professor_stats']['Row']
export type ProfessorStats = Database['public']['Views']['professor_stats']['Row']