CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Upgrade to Prisma v7 with PrismaPg adapter and Next.js 15 compatibility #3909

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/setup-build-environment-WyVzdmainopened Apr 12, 2026
3 changed files+355−0
Modifiedapp/(platform)/dashboard/page.tsx+6−0View fileUnifiedSplit
33import { getServerSession } from "next-auth";
44import { authOptions } from "@/lib/auth";
55import { prisma } from "@/lib/prisma";
6import MorningBriefing from "@/app/components/platform/MorningBriefing";
67
78export const dynamic = "force-dynamic";
89
224225 </Link>
225226 </div>
226227
228 {/* Morning Briefing — listen to your news on the commute */}
229 <div className="mt-6">
230 <MorningBriefing />
231 </div>
232
227233 {/* Activity + recent matters */}
228234 <div className="mt-8 grid gap-6 lg:grid-cols-3">
229235 {/* Recent matters */}
Addedapp/api/news/briefing/route.ts+135−0View fileUnifiedSplit
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { prisma } from "@/lib/prisma";
5import type { NewsItem } from "../route";
6
7export const runtime = "nodejs";
8
9export async function GET() {
10 const session = await getServerSession(authOptions);
11 const userId = (session?.user as { id?: string } | undefined)?.id;
12 if (!userId) {
13 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14 }
15
16 try {
17 // Get user's practice area for personalisation
18 const user = await prisma.user.findUnique({
19 where: { id: userId },
20 select: { practiceArea: true, jurisdiction: true, name: true },
21 });
22
23 // Fetch today's news
24 const newsRes = await fetch(
25 `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/api/news?limit=20`,
26 {
27 headers: {
28 cookie: `next-auth.session-token=${userId}`,
29 },
30 },
31 ).catch(() => null);
32
33 let headlines: NewsItem[] = [];
34 if (newsRes?.ok) {
35 const data = await newsRes.json();
36 headlines = data.items || [];
37 }
38
39 // Build the briefing text
40 const firstName = user?.name?.split(" ")[0] || "there";
41 const now = new Date();
42 const timeOfDay =
43 now.getHours() < 12 ? "morning" : now.getHours() < 17 ? "afternoon" : "evening";
44 const dateStr = new Intl.DateTimeFormat("en-US", {
45 weekday: "long",
46 month: "long",
47 day: "numeric",
48 }).format(now);
49
50 // Separate by category
51 const legalHeadlines = headlines
52 .filter((h) => h.category === "legal" || h.category === "court")
53 .slice(0, 4);
54 const accountingHeadlines = headlines
55 .filter((h) => h.category === "accounting" || h.category === "tax")
56 .slice(0, 3);
57 const ipHeadlines = headlines
58 .filter((h) => h.category === "ip")
59 .slice(0, 2);
60
61 // Build structured briefing
62 const sections: Array<{
63 title: string;
64 items: Array<{ headline: string; source: string; link: string }>;
65 }> = [];
66
67 if (legalHeadlines.length > 0) {
68 sections.push({
69 title: "In legal news",
70 items: legalHeadlines.map((h) => ({
71 headline: h.title,
72 source: h.source,
73 link: h.link,
74 })),
75 });
76 }
77
78 if (accountingHeadlines.length > 0) {
79 sections.push({
80 title: "In accounting and tax",
81 items: accountingHeadlines.map((h) => ({
82 headline: h.title,
83 source: h.source,
84 link: h.link,
85 })),
86 });
87 }
88
89 if (ipHeadlines.length > 0) {
90 sections.push({
91 title: "In intellectual property",
92 items: ipHeadlines.map((h) => ({
93 headline: h.title,
94 source: h.source,
95 link: h.link,
96 })),
97 });
98 }
99
100 // Generate the spoken briefing script
101 let script = `Good ${timeOfDay}, ${firstName}. It's ${dateStr}. Here's your Marco Reid morning briefing.\n\n`;
102
103 for (const section of sections) {
104 script += `${section.title}.\n`;
105 section.items.forEach((item, i) => {
106 script += `${i + 1}. ${item.headline}. From ${item.source}.\n`;
107 });
108 script += "\n";
109 }
110
111 if (sections.length === 0) {
112 script +=
113 "No new articles this morning. I'll check again in 30 minutes. In the meantime, is there anything you'd like to research?\n";
114 } else {
115 script +=
116 "That's your briefing. Would you like me to go deeper on any of these stories? Just say the number, or ask me anything.\n";
117 }
118
119 // Return both structured data and the spoken script
120 return NextResponse.json({
121 greeting: `Good ${timeOfDay}, ${firstName}.`,
122 date: dateStr,
123 sections,
124 script,
125 practiceArea: user?.practiceArea || null,
126 jurisdiction: user?.jurisdiction || null,
127 headlineCount: headlines.length,
128 });
129 } catch {
130 return NextResponse.json(
131 { error: "Could not generate briefing." },
132 { status: 500 },
133 );
134 }
135}
Addedapp/components/platform/MorningBriefing.tsx+214−0View fileUnifiedSplit
1"use client";
2
3import { useCallback, useEffect, useRef, useState } from "react";
4import Link from "next/link";
5
6interface BriefingSection {
7 title: string;
8 items: Array<{ headline: string; source: string; link: string }>;
9}
10
11interface BriefingData {
12 greeting: string;
13 date: string;
14 sections: BriefingSection[];
15 script: string;
16 headlineCount: number;
17}
18
19export default function MorningBriefing() {
20 const [briefing, setBriefing] = useState<BriefingData | null>(null);
21 const [loading, setLoading] = useState(true);
22 const [playing, setPlaying] = useState(false);
23 const [currentLine, setCurrentLine] = useState(-1);
24 const synthRef = useRef<SpeechSynthesisUtterance | null>(null);
25
26 useEffect(() => {
27 fetch("/api/news/briefing")
28 .then(async (res) => {
29 if (!res.ok) return;
30 const data = await res.json();
31 setBriefing(data);
32 })
33 .catch(() => {})
34 .finally(() => setLoading(false));
35 }, []);
36
37 const speak = useCallback(() => {
38 if (!briefing?.script) return;
39 if (playing) {
40 window.speechSynthesis.cancel();
41 setPlaying(false);
42 setCurrentLine(-1);
43 return;
44 }
45
46 const lines = briefing.script.split("\n").filter((l) => l.trim());
47 let lineIndex = 0;
48
49 function speakLine() {
50 if (lineIndex >= lines.length) {
51 setPlaying(false);
52 setCurrentLine(-1);
53 return;
54 }
55
56 const utterance = new SpeechSynthesisUtterance(lines[lineIndex]);
57 synthRef.current = utterance;
58
59 // Use a professional-sounding voice if available
60 const voices = window.speechSynthesis.getVoices();
61 const preferred = voices.find(
62 (v) =>
63 v.name.includes("Samantha") ||
64 v.name.includes("Daniel") ||
65 v.name.includes("Karen") ||
66 v.name.includes("Google UK English") ||
67 (v.lang.startsWith("en") && v.name.includes("Premium")),
68 );
69 if (preferred) utterance.voice = preferred;
70
71 utterance.rate = 0.95;
72 utterance.pitch = 1.0;
73
74 setCurrentLine(lineIndex);
75
76 utterance.onend = () => {
77 lineIndex++;
78 speakLine();
79 };
80
81 utterance.onerror = () => {
82 setPlaying(false);
83 setCurrentLine(-1);
84 };
85
86 window.speechSynthesis.speak(utterance);
87 }
88
89 setPlaying(true);
90 speakLine();
91 }, [briefing, playing]);
92
93 // Cleanup on unmount
94 useEffect(() => {
95 return () => {
96 window.speechSynthesis.cancel();
97 };
98 }, []);
99
100 if (loading) {
101 return (
102 <div className="animate-pulse rounded-2xl border border-navy-100 bg-white p-6 dark:border-navy-700 dark:bg-navy-800">
103 <div className="h-4 w-1/3 rounded bg-navy-100 dark:bg-navy-700" />
104 <div className="mt-3 h-6 w-2/3 rounded bg-navy-100 dark:bg-navy-700" />
105 </div>
106 );
107 }
108
109 if (!briefing) return null;
110
111 const lines = briefing.script.split("\n").filter((l) => l.trim());
112
113 return (
114 <div className="rounded-2xl border border-gold-200 bg-gradient-to-br from-gold-50 via-white to-navy-50 p-6 shadow-card dark:border-gold-800 dark:from-navy-800 dark:via-navy-800 dark:to-navy-900">
115 <div className="flex items-start justify-between gap-4">
116 <div>
117 <p className="text-xs font-semibold uppercase tracking-[0.2em] text-gold-600 dark:text-gold-400">
118 Morning Briefing
119 </p>
120 <p className="mt-1 font-serif text-xl text-navy-800 dark:text-white">
121 {briefing.greeting}
122 </p>
123 <p className="mt-1 text-sm text-navy-400">{briefing.date}</p>
124 </div>
125
126 {/* Play / Stop button */}
127 <button
128 onClick={speak}
129 className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full transition-all ${
130 playing
131 ? "bg-red-500 text-white shadow-lg shadow-red-500/30 animate-pulse"
132 : "bg-gold-500 text-white shadow-lg shadow-gold-500/30 hover:bg-gold-600 hover:scale-105"
133 }`}
134 aria-label={playing ? "Stop briefing" : "Play briefing"}
135 title={playing ? "Stop" : "Listen to your briefing"}
136 >
137 {playing ? (
138 <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
139 <rect x="6" y="6" width="12" height="12" rx="2" />
140 </svg>
141 ) : (
142 <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
143 <path d="M8 5v14l11-7z" />
144 </svg>
145 )}
146 </button>
147 </div>
148
149 {/* Briefing content */}
150 {briefing.sections.length > 0 ? (
151 <div className="mt-5 space-y-4">
152 {briefing.sections.map((section) => (
153 <div key={section.title}>
154 <p className="text-xs font-semibold uppercase tracking-wider text-navy-400 dark:text-navy-400">
155 {section.title}
156 </p>
157 <ul className="mt-2 space-y-2">
158 {section.items.map((item, i) => (
159 <li key={i}>
160 <a
161 href={item.link}
162 target="_blank"
163 rel="noopener noreferrer"
164 className={`block rounded-lg px-3 py-2 text-sm transition-colors hover:bg-gold-50 dark:hover:bg-navy-700 ${
165 playing && currentLine >= 0
166 ? "opacity-60"
167 : ""
168 }`}
169 >
170 <span className="font-medium text-navy-700 dark:text-navy-200">
171 {item.headline}
172 </span>
173 <span className="ml-2 text-xs text-navy-400">
174 {item.source}
175 </span>
176 </a>
177 </li>
178 ))}
179 </ul>
180 </div>
181 ))}
182 </div>
183 ) : (
184 <p className="mt-4 text-sm text-navy-400">
185 No new articles this morning. Check back shortly.
186 </p>
187 )}
188
189 {/* Transcript when playing */}
190 {playing && (
191 <div className="mt-4 rounded-xl border border-gold-200 bg-white/50 p-4 dark:border-gold-800 dark:bg-navy-900/50">
192 <p className="text-xs font-semibold uppercase tracking-wider text-gold-600 dark:text-gold-400">
193 Now reading
194 </p>
195 <p className="mt-2 text-sm text-navy-700 dark:text-navy-200">
196 {lines[currentLine] || "..."}
197 </p>
198 </div>
199 )}
200
201 <div className="mt-4 flex items-center justify-between">
202 <p className="text-xs text-navy-400">
203 {briefing.headlineCount} articles from {briefing.sections.length} categories
204 </p>
205 <Link
206 href="/news"
207 className="text-xs font-medium text-gold-600 hover:text-gold-700 dark:text-gold-400"
208 >
209 Full news feed &rarr;
210 </Link>
211 </div>
212 </div>
213 );
214}
0215
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts