Resolve conflict in .env

This commit is contained in:
Alma 2025-04-14 00:44:58 +02:00
commit 6b9c8ccc44
7 changed files with 283 additions and 460 deletions

2
.env
View File

@ -48,6 +48,8 @@ ROCKET_CHAT_USER_ID=Tpuww59PJKsrGNQJB
LEANTIME_TOKEN=lt_lsdShQdoYHaPUWuL07XZR1Rf3GeySsIs_UDlll3VJPk5EwAuILpMC4BwzJ9MZFRrb
LEANTIME_API_URL=https://agilite.slm-lab.net
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/calendar_db?schema=public"
NEWSDB_URL=postgresql://alma:Sict33711###@cube.governance-labs.com/rivacube

View File

@ -7,7 +7,6 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
console.error('No session or email found');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@ -20,152 +19,35 @@ export async function GET(req: NextRequest) {
);
}
// Get the Keycloak token from the session
const keycloakToken = session.accessToken;
if (!keycloakToken) {
console.error('Missing Keycloak token in session');
return NextResponse.json(
{ error: 'Authentication token is missing' },
{ status: 401 }
);
}
// First, try the unified inbox endpoint for unread messages
const unifiedResponse = await fetch(
`${nextcloudUrl}/index.php/apps/mail/api/messages?filter=is:unread`,
{
headers: {
'Authorization': `Bearer ${keycloakToken}`,
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'X-Requested-With': 'XMLHttpRequest'
}
}
);
// Log the response status and headers for debugging
console.log('Unified inbox response status:', unifiedResponse.status);
console.log('Unified inbox response headers:', Object.fromEntries(unifiedResponse.headers.entries()));
if (unifiedResponse.ok) {
const unifiedData = await unifiedResponse.json();
console.log('Unified inbox data:', unifiedData);
const messages = unifiedData.data || [];
const unreadEmails = messages.map((msg: any) => ({
id: msg.id,
subject: msg.subject,
sender: {
name: msg.from[0].label || msg.from[0].email,
email: msg.from[0].email
},
date: msg.date,
isUnread: true
}));
return NextResponse.json({
emails: unreadEmails,
mailUrl: `${nextcloudUrl}/apps/mail/box/unified`
});
}
// If unified inbox fails, fall back to the account-based approach
const accountsResponse = await fetch(
`${nextcloudUrl}/index.php/apps/mail/api/accounts`,
{
headers: {
'Authorization': `Bearer ${keycloakToken}`,
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'X-Requested-With': 'XMLHttpRequest'
}
}
);
// Log the response status and headers for debugging
console.log('Accounts response status:', accountsResponse.status);
console.log('Accounts response headers:', Object.fromEntries(accountsResponse.headers.entries()));
if (!accountsResponse.ok) {
const errorText = await accountsResponse.text();
console.error('Failed to fetch mail accounts. Response:', errorText);
// Test Nextcloud connectivity
const testResponse = await fetch(`${nextcloudUrl}/status.php`);
if (!testResponse.ok) {
console.error('Nextcloud is not accessible:', await testResponse.text());
return NextResponse.json(
{
error: "L'application Mail n'est pas disponible sur Nextcloud. Veuillez contacter votre administrateur.",
error: "Nextcloud n'est pas accessible. Veuillez contacter votre administrateur.",
emails: []
},
{ status: 404 }
{ status: 503 }
);
}
const accountsData = await accountsResponse.json();
console.log('Accounts data:', accountsData);
const accounts = accountsData.data || [];
const unreadEmails = [];
for (const account of accounts) {
// Get mailboxes for the account
const mailboxesResponse = await fetch(
`${nextcloudUrl}/index.php/apps/mail/api/accounts/${account.id}/mailboxes`,
{
headers: {
'Authorization': `Bearer ${keycloakToken}`,
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'X-Requested-With': 'XMLHttpRequest'
}
}
);
if (!mailboxesResponse.ok) {
console.error(`Failed to fetch mailboxes for account ${account.id}`);
continue;
}
const mailboxesData = await mailboxesResponse.json();
const mailboxes = mailboxesData.data || [];
// Get unread messages from each mailbox
for (const mailbox of mailboxes) {
const messagesResponse = await fetch(
`${nextcloudUrl}/index.php/apps/mail/api/accounts/${account.id}/mailboxes/${mailbox.id}/messages?filter=is:unread`,
{
headers: {
'Authorization': `Bearer ${keycloakToken}`,
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'X-Requested-With': 'XMLHttpRequest'
}
}
);
if (!messagesResponse.ok) {
console.error(`Failed to fetch messages for mailbox ${mailbox.id}`);
continue;
}
const messagesData = await messagesResponse.json();
const messages = messagesData.data || [];
unreadEmails.push(...messages.map((msg: any) => ({
id: msg.id,
subject: msg.subject,
sender: {
name: msg.from[0].label || msg.from[0].email,
email: msg.from[0].email
},
date: msg.date,
isUnread: true
})));
}
}
// For now, return a test response
return NextResponse.json({
emails: unreadEmails,
emails: [{
id: 'test-1',
subject: 'Test Email',
sender: {
name: 'System',
email: 'system@example.com'
},
date: new Date().toISOString(),
isUnread: true
}],
mailUrl: `${nextcloudUrl}/apps/mail/box/unified`
});
} catch (error) {
console.error('Error getting emails:', error);
console.error('Error:', error);
return NextResponse.json(
{
error: "Une erreur est survenue. Veuillez contacter votre administrateur.",

View File

@ -1,136 +1,29 @@
import { NextResponse } from 'next/server';
import { Pool } from 'pg';
// Function to clean database host URL
function cleanDatabaseHost(host: string | undefined): string {
if (!host) {
throw new Error('Database host is not defined');
}
// Remove any protocol and trailing slashes
return host.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
// Create connection configuration
const dbConfig = {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
host: cleanDatabaseHost(process.env.DB_HOST),
database: process.env.DB_NAME,
port: 5432, // Default PostgreSQL port
ssl: {
rejectUnauthorized: false
}
};
// Create a new pool using the configuration
const pool = new Pool(dbConfig);
// Mock data for development
const MOCK_NEWS = [
{
id: 1,
title: "New Project Management Features Released",
url: "#",
date: "2024-03-20",
source: "Internal",
description: "New features added to improve project management workflow",
category: "Update",
sentiment: { score: null, label: null },
symbols: null,
symbol: null
},
{
id: 2,
title: "Team Meeting Schedule Changes",
url: "#",
date: "2024-03-19",
source: "Internal",
description: "Updates to the team meeting schedule",
category: "Announcement",
sentiment: { score: null, label: null },
symbols: null,
symbol: null
},
{
id: 3,
title: "Upcoming Training Sessions",
url: "#",
date: "2024-03-18",
source: "Internal",
description: "Schedule for upcoming training sessions",
category: "Training",
sentiment: { score: null, label: null },
symbols: null,
symbol: null
}
];
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
console.log('Attempting database connection with config:', {
user: dbConfig.user,
host: dbConfig.host,
database: dbConfig.database,
// Excluding password for security
const news = await prisma.news.findMany({
orderBy: {
date: 'desc'
},
take: 5,
select: {
id: true,
title: true,
date: true,
source: true,
description: true,
category: true,
url: true
}
});
// Connect to the database
const client = await pool.connect();
console.log('Successfully connected to database');
try {
console.log('Executing news query...');
// Query the news table for the latest 10 news items
const result = await client.query(`
SELECT
id,
title,
url,
date,
source,
description,
category,
sentiment_score,
sentiment,
symbols,
symbol
FROM news
ORDER BY date DESC
LIMIT 10
`);
console.log(`Found ${result.rows.length} news items`);
// Format the response
const news = result.rows.map(row => ({
id: row.id,
title: row.title,
url: row.url,
date: row.date,
source: row.source,
description: row.description,
category: row.category,
sentiment: {
score: row.sentiment_score,
label: row.sentiment
},
symbols: row.symbols,
symbol: row.symbol
}));
return NextResponse.json({ news });
} finally {
// Release the client back to the pool
client.release();
console.log('Database client released');
}
return NextResponse.json(news);
} catch (error) {
console.error('Database error:', error);
console.error('Error fetching news:', error);
return NextResponse.json(
{
error: 'Failed to fetch news',
details: error instanceof Error ? error.message : String(error)
},
{ error: 'Failed to fetch news' },
{ status: 500 }
);
}

View File

@ -90,7 +90,7 @@ export function Calendar() {
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800">Calendar</CardTitle>
<CardTitle className="text-lg font-semibold text-gray-800">Agenda</CardTitle>
<Button
variant="ghost"
size="icon"
@ -118,24 +118,36 @@ export function Calendar() {
>
<div className="flex gap-2">
<div
className="flex-shrink-0 w-12 h-12 rounded-lg flex flex-col items-center justify-center border"
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
style={{
backgroundColor: `${event.calendarColor}10`,
borderColor: event.calendarColor
}}
>
<CalendarIcon className="h-4 w-4" style={{ color: event.calendarColor }} />
<span
className="text-[10px] font-medium mt-0.5"
className="text-[10px] font-medium"
style={{ color: event.calendarColor }}
>
{formatDate(event.start)}
</span>
<span
className="text-[10px] font-bold mt-0.5"
style={{ color: event.calendarColor }}
>
{formatTime(event.start)}
</span>
</div>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-gray-800 line-clamp-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
{event.title}
</p>
{!event.allDay && (
<span className="text-[10px] text-gray-500 whitespace-nowrap">
{formatTime(event.start)} - {formatTime(event.end)}
</span>
)}
</div>
<div
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
style={{

View File

@ -5,20 +5,101 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, Mail } from "lucide-react";
import { useSession, signIn } from "next-auth/react";
import { formatDistance } from 'date-fns/formatDistance';
import { fr } from 'date-fns/locale/fr';
interface Email {
id: string;
subject: string;
sender: {
name: string;
email: string;
};
date: string;
isUnread: boolean;
}
interface EmailResponse {
emails: Email[];
mailUrl: string;
error?: string;
}
export function Email() {
const [emails, setEmails] = useState<Email[]>([]);
const [mailUrl, setMailUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const { status } = useSession();
const fetchEmails = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
if (!isRefresh) setLoading(true);
try {
const response = await fetch('/api/emails');
const data: EmailResponse = await response.json();
if (!response.ok) {
// Handle session expiration
if (response.status === 401) {
signIn(); // Redirect to login
return;
}
// Handle specific error messages
if (response.status === 404) {
setError("L'application Mail n'est pas disponible sur Nextcloud. Veuillez contacter votre administrateur.");
return;
}
throw new Error(data.error || 'Failed to fetch emails');
}
setEmails(data.emails || []);
setMailUrl(data.mailUrl);
setError(null);
} catch (err) {
console.error('Error fetching emails:', err);
setError(err instanceof Error ? err.message : 'Erreur lors de la récupération des emails');
} finally {
setLoading(false);
setRefreshing(false);
}
};
// Initial fetch
useEffect(() => {
if (status === 'authenticated') {
setLoading(false);
} else if (status === 'unauthenticated') {
signIn();
fetchEmails();
}
}, [status]);
// Auto-refresh every 5 minutes
useEffect(() => {
if (status !== 'authenticated') return;
const interval = setInterval(() => {
fetchEmails(true);
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [status]);
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
locale: fr
});
} catch (err) {
console.error('Error formatting date:', err);
return dateString;
}
};
if (status === 'loading' || loading) {
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
@ -26,7 +107,7 @@ export function Email() {
<CardTitle className="text-lg font-semibold text-gray-800">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5" />
<span>Emails</span>
<span>Emails non lus</span>
</div>
</CardTitle>
</CardHeader>
@ -45,16 +126,50 @@ export function Email() {
<CardTitle className="text-lg font-semibold text-gray-800">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5" />
<span>Emails</span>
<span>Emails non lus</span>
</div>
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchEmails(true)}
disabled={refreshing}
className={`${refreshing ? 'animate-spin' : ''} text-gray-600 hover:text-gray-900`}
>
<RefreshCw className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="p-0 h-[calc(100%-3.5rem)]">
<iframe
src="https://lab.slm-lab.net/email"
className="w-full h-full border-0"
title="Email Inbox"
/>
<CardContent className="p-3">
{error ? (
<p className="text-center text-red-500">{error}</p>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto">
{emails.length === 0 ? (
<p className="text-center text-gray-500">Aucun email non lu</p>
) : (
emails.map((email) => (
<div
key={email.id}
className="p-2 hover:bg-gray-50/50 rounded-lg transition-colors cursor-pointer"
onClick={() => mailUrl && window.open(mailUrl, '_blank')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600 truncate max-w-[60%]" title={email.sender.name}>
{email.sender.name}
</span>
<div className="flex items-center space-x-2">
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full"></span>
<span className="text-xs text-gray-500">{formatDate(email.date)}</span>
</div>
</div>
<h3 className="text-sm font-semibold text-gray-800 line-clamp-2" title={email.subject}>
{email.subject}
</h3>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
);

View File

@ -1,36 +1,21 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDistanceToNow } from 'date-fns';
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
import { useSession } from "next-auth/react";
import { formatDistance } from 'date-fns';
import { fr } from 'date-fns/locale';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
interface NewsItem {
id: number;
title: string;
url: string;
date: string;
source: string;
description: string;
category: string;
sentiment: {
score: number | null;
label: string | null;
};
symbols: string[] | null;
symbol: string | null;
}
interface DebugState {
newsCount: number;
loading: boolean;
error: string | null;
dbStatus: string;
lastUpdate: string;
lastError: string | null;
description: string | null;
category: string | null;
url: string;
}
export function News() {
@ -38,204 +23,113 @@ export function News() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [dbStatus, setDbStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
const [debugState, setDebugState] = useState<DebugState>({
newsCount: 0,
loading: true,
error: null,
dbStatus: 'connecting',
lastUpdate: new Date().toISOString(),
lastError: null
});
const { status } = useSession();
const updateDebugState = useCallback((updates: Partial<DebugState>) => {
setDebugState(prev => {
const newState = { ...prev, ...updates, lastUpdate: new Date().toISOString() };
console.table(newState);
return newState;
});
}, []);
const fetchNews = useCallback(async (isRefresh = false) => {
updateDebugState({ loading: true, dbStatus: 'connecting' });
if (isRefresh) {
setRefreshing(true);
updateDebugState({ lastError: null });
}
setLoading(true);
setDbStatus('connecting');
const fetchNews = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
if (!isRefresh) setLoading(true);
try {
updateDebugState({ dbStatus: 'fetching' });
const response = await fetch('/api/news');
updateDebugState({ dbStatus: response.ok ? 'received' : 'error' });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
throw new Error('Failed to fetch news');
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
setNews(data.news || []);
setNews(data);
setError(null);
setDbStatus('connected');
updateDebugState({
newsCount: (data.news || []).length,
error: null,
dbStatus: 'connected',
loading: false
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load news';
setError(errorMessage);
setDbStatus('error');
setNews([]);
updateDebugState({
error: errorMessage,
dbStatus: 'error',
loading: false,
lastError: errorMessage
});
setError('Failed to fetch news');
console.error('Error fetching news:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [updateDebugState]);
};
useEffect(() => {
updateDebugState({ dbStatus: 'initializing' });
if (status === 'authenticated') {
fetchNews();
}
}, [status]);
return () => {
updateDebugState({ dbStatus: 'unmounting' });
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
locale: fr
});
} catch (err) {
console.error('Error formatting date:', err);
return dateString;
}
};
}, [fetchNews, updateDebugState]);
const DebugInfo = () => (
<div className="text-xs text-gray-500 mt-2 p-2 bg-gray-100 rounded">
<p>Status: {debugState.dbStatus}</p>
<p>Loading: {debugState.loading ? 'true' : 'false'}</p>
<p>Error: {debugState.error || 'none'}</p>
<p>News items: {debugState.newsCount}</p>
<p>Last update: {new Date(debugState.lastUpdate).toLocaleTimeString()}</p>
{debugState.lastError && (
<p className="text-red-500">Last error: {debugState.lastError}</p>
)}
<details>
<summary>Debug Details</summary>
<pre className="mt-2 text-[10px] whitespace-pre-wrap">
{JSON.stringify(news, null, 2)}
</pre>
</details>
</div>
);
if (loading && !refreshing) {
if (status === 'loading' || loading) {
return (
<Card className="w-full">
<CardHeader>
<CardTitle>News</CardTitle>
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800">News</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center h-32 space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<p className="text-sm text-gray-500">
{dbStatus === 'connecting' ? 'Connecting to database...' : 'Loading news...'}
</p>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-gray-400" />
</div>
<DebugInfo />
</CardContent>
</Card>
);
}
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>News</CardTitle>
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-x-4 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800">News</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchNews(true)}
disabled={refreshing}
className={`${refreshing ? 'animate-spin' : ''}`}
className={`${refreshing ? 'animate-spin' : ''} text-gray-600 hover:text-gray-900`}
>
<RefreshCw className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
<CardContent className="p-3">
{error ? (
<div className="text-red-500 space-y-2">
<p>{error}</p>
<p className="text-sm text-gray-500">
{dbStatus === 'error' ? 'Database connection error' : 'Failed to fetch news'}
</p>
</div>
) : news.length === 0 ? (
<div className="text-center text-gray-500 py-8">
No news available
</div>
<p className="text-center text-red-500">{error}</p>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto">
{news.length === 0 ? (
<p className="text-center text-gray-500">No news available</p>
) : (
news.map((item) => (
<div key={item.id} className="border-b pb-4 last:border-b-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<Link
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium"
<div
key={item.id}
className="p-2 hover:bg-gray-50/50 rounded-lg transition-colors cursor-pointer"
onClick={() => window.open(item.url, '_blank')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-500">{formatDate(item.date)}</span>
{item.category && (
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600">
{item.category}
</span>
)}
</div>
<h3 className="text-sm font-medium text-gray-800 line-clamp-2" title={item.title}>
{item.title}
</Link>
<p className="text-sm text-gray-500 mt-1">
{item.source} {formatDistanceToNow(new Date(item.date), { addSuffix: true, locale: fr })}
</p>
</h3>
{item.description && (
<p className="text-sm text-gray-600 mt-2 line-clamp-2">
{item.description.replace(/<[^>]*>/g, '')}
<p className="text-xs text-gray-500 mt-1 line-clamp-2" title={item.description}>
{item.description}
</p>
)}
</div>
{item.sentiment.score !== null && (
<div className={`ml-4 px-2 py-1 rounded text-sm ${
item.sentiment.score > 0 ? 'bg-green-100 text-green-800' :
item.sentiment.score < 0 ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{item.sentiment.label || 'Neutral'}
</div>
)}
</div>
{(item.symbols?.length || item.symbol) && (
<div className="mt-2 flex flex-wrap gap-2">
{item.symbols?.map((symbol, index) => (
<span key={index} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
{symbol}
</span>
))}
{item.symbol && !item.symbols?.includes(item.symbol) && (
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
{item.symbol}
</span>
)}
</div>
)}
</div>
))
)}
</div>
<DebugInfo />
)}
</CardContent>
</Card>
);

View File

@ -11,6 +11,11 @@ datasource db {
url = env("DATABASE_URL")
}
datasource newsdb {
provider = "postgresql"
url = env("NEWSDB_URL")
}
model Calendar {
id String @id @default(uuid())
name String
@ -41,3 +46,23 @@ model Event {
@@index([calendarId])
@@index([userId])
}
model News {
id Int @id @default(autoincrement())
title String
url String @unique
date DateTime
source String
content String?
sentiment_score Float?
sentiment String?
symbols String[]
symbol String?
processed_at DateTime @default(now())
description String?
category String? @db.VarChar(50)
@@index([category])
@@index([date])
@@index([symbol])
}