/**
* API Route: /api/feed
*
* GET: Fetch curated personalized activity feed for authenticated user
*
* Returns tiered content:
* 1. Parliamentary events (global) - votes, royal assent - shown to everyone
* 2. Breaking news - high score, recent articles
* 3. Personalized - matches user's bookmarked entities
* 4. Trending - high engagement across users
* 5. Discovery - scored news articles
* 6. Fallback - lower-scored content if feed would be empty
*
* Never returns an empty feed - falls back gracefully.
*/
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { supabase } from '@/lib/supabase-admin';
// Valid activity types
const VALID_ACTIVITY_TYPES = ['vote', 'bill_update', 'committee_meeting', 'mention', 'news_mention', 'lobbying'] as const;
type ActivityType = typeof VALID_ACTIVITY_TYPES[number];
// Map postal code first letter to province
const POSTAL_PROVINCE_MAP: Record<string, string> = {
'A': 'NL', 'B': 'NS', 'C': 'PE', 'E': 'NB',
'G': 'QC', 'H': 'QC', 'J': 'QC',
'K': 'ON', 'L': 'ON', 'M': 'ON', 'N': 'ON', 'P': 'ON',
'R': 'MB', 'S': 'SK', 'T': 'AB',
'V': 'BC', 'X': 'NT', 'Y': 'YT',
};
/**
* Derive province code from postal code
*/
function getProvinceFromPostalCode(postalCode: string | null): string | null {
if (!postalCode) return null;
const firstChar = postalCode.charAt(0).toUpperCase();
return POSTAL_PROVINCE_MAP[firstChar] || null;
}
export interface ActivitySource {
type: 'bookmark' | 'suggested';
entityType: string;
entityId: string;
entityName: string | null;
}
export interface ActivityItem {
id: string;
activity_type: ActivityType;
entity_type: 'mp' | 'bill' | 'committee' | 'user';
entity_id: string;
title: string;
description: string | null;
metadata: Record<string, unknown>;
occurred_at: string;
source?: ActivitySource;
relevance_score?: number;
tier?: 'parliamentary' | 'breaking' | 'personalized' | 'trending' | 'suggested' | 'discovery' | 'fallback';
is_global?: boolean;
}
export interface FeedResponse {
items: ActivityItem[];
count: number;
hasMore: boolean;
filters: {
types: ActivityType[];
};
}
// Minimum items to show before falling back to lower-scored content
const MIN_FEED_ITEMS = 10;
const MIN_PREFERRED_SCORE = 30;
export async function GET(request: NextRequest) {
try {
// Require authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const userId = session.user.id;
// Parse query parameters
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
// Parse activity types filter
const typesParam = searchParams.get('types');
let activityTypes: ActivityType[] = [...VALID_ACTIVITY_TYPES];
if (typesParam) {
const requestedTypes = typesParam.split(',').filter(
(t): t is ActivityType => VALID_ACTIVITY_TYPES.includes(t as ActivityType)
);
if (requestedTypes.length > 0) {
activityTypes = requestedTypes;
}
}
// Get user's preferred MP and postal code from their profile
const { data: profile } = await supabase
.from('user_profiles')
.select('preferred_mp_id, postal_code')
.eq('id', userId)
.single();
const preferredMpId = profile?.preferred_mp_id || null;
const userProvince = getProvinceFromPostalCode(profile?.postal_code);
// Try the new curated feed function first
const { data: curatedItems, error: curatedError } = await supabase.rpc(
'get_curated_news_feed',
{
p_user_id: userId,
p_preferred_mp_id: preferredMpId,
p_user_province: userProvince,
p_limit: limit + 1, // Fetch one extra to check hasMore
p_offset: offset,
p_activity_types: activityTypes,
p_min_score: MIN_PREFERRED_SCORE,
}
);
// If curated feed function exists and works, use it
if (!curatedError && curatedItems) {
// Check if we have enough items with preferred score
let items = curatedItems;
// Soft threshold: if we don't have enough items, fetch fallback content
if (items.length < MIN_FEED_ITEMS && offset === 0) {
const { data: fallbackItems } = await supabase.rpc(
'get_curated_news_feed',
{
p_user_id: userId,
p_preferred_mp_id: preferredMpId,
p_user_province: userProvince,
p_limit: limit + 1 - items.length,
p_offset: 0,
p_activity_types: activityTypes,
p_min_score: 0, // Include all scored content
}
);
if (fallbackItems) {
// Merge items, avoiding duplicates
const existingIds = new Set(items.map((i: { id: string }) => i.id));
const newItems = fallbackItems.filter((i: { id: string }) => !existingIds.has(i.id));
items = [...items, ...newItems].slice(0, limit + 1);
}
}
const hasMore = items.length > limit;
const rawItems = hasMore ? items.slice(0, limit) : items;
// Map items with full attribution
const feedItems: ActivityItem[] = rawItems.map((item: {
id: string;
activity_type: ActivityType;
entity_type: 'mp' | 'bill' | 'committee' | 'user';
entity_id: string;
title: string;
description: string | null;
metadata: Record<string, unknown>;
occurred_at: string;
created_at?: string;
relevance_score?: number;
tier?: string;
is_global?: boolean;
source_type?: string;
source_entity_type?: string;
source_entity_id?: string;
source_entity_name?: string;
}) => ({
id: item.id,
activity_type: item.activity_type,
entity_type: item.entity_type,
entity_id: item.entity_id,
title: item.title,
description: item.description,
metadata: item.metadata,
occurred_at: item.occurred_at,
relevance_score: item.relevance_score,
tier: item.tier as ActivityItem['tier'],
is_global: item.is_global,
source: item.source_type ? {
type: item.source_type as 'bookmark' | 'suggested',
entityType: item.source_entity_type || '',
entityId: item.source_entity_id || '',
entityName: item.source_entity_name || null,
} : undefined,
}));
return NextResponse.json({
items: feedItems,
count: feedItems.length,
hasMore,
filters: { types: activityTypes },
});
}
// Fall back to v2 if curated feed doesn't exist yet
console.log('Curated feed not available, falling back to v2:', curatedError?.message);
const { data: items, error: feedError } = await supabase.rpc(
'get_user_activity_feed_v2',
{
p_user_id: userId,
p_preferred_mp_id: preferredMpId,
p_user_province: userProvince,
p_limit: limit + 1,
p_offset: offset,
p_activity_types: activityTypes,
}
);
if (feedError) {
console.error('Error fetching v2 activity feed:', feedError);
// Fall back to v1 if v2 doesn't exist yet
const { data: v1Items, error: v1Error } = await supabase.rpc(
'get_user_activity_feed',
{
p_user_id: userId,
p_preferred_mp_id: preferredMpId,
p_limit: limit + 1,
p_offset: offset,
p_activity_types: activityTypes,
}
);
if (v1Error) {
console.error('Error fetching v1 activity feed:', v1Error);
return NextResponse.json(
{ error: 'Failed to fetch activity feed' },
{ status: 500 }
);
}
const hasMore = v1Items && v1Items.length > limit;
const feedItems = hasMore ? v1Items.slice(0, limit) : (v1Items || []);
return NextResponse.json({
items: feedItems,
count: feedItems.length,
hasMore,
filters: { types: activityTypes },
});
}
// Process v2 results
const hasMore = items && items.length > limit;
const rawItems = hasMore ? items.slice(0, limit) : (items || []);
// Map items with source attribution
const feedItems: ActivityItem[] = rawItems.map((item: {
id: string;
activity_type: ActivityType;
entity_type: 'mp' | 'bill' | 'committee' | 'user';
entity_id: string;
title: string;
description: string | null;
metadata: Record<string, unknown>;
occurred_at: string;
source_type?: string;
source_entity_type?: string;
source_entity_id?: string;
source_entity_name?: string;
}) => ({
id: item.id,
activity_type: item.activity_type,
entity_type: item.entity_type,
entity_id: item.entity_id,
title: item.title,
description: item.description,
metadata: item.metadata,
occurred_at: item.occurred_at,
source: item.source_type ? {
type: item.source_type as 'bookmark' | 'suggested',
entityType: item.source_entity_type || '',
entityId: item.source_entity_id || '',
entityName: item.source_entity_name || null,
} : undefined,
}));
const response: FeedResponse = {
items: feedItems,
count: feedItems.length,
hasMore,
filters: {
types: activityTypes,
},
};
return NextResponse.json(response);
} catch (error) {
console.error('Error in GET /api/feed:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}