fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché - Couverture note: explicite (choix/aucune), plus d’auto depuis le corps - Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée - Slash: listes par titre (plus d’index), keywords nettoyés - Agents: nextRun à la réactivation, cron sans stampede null - Wizard: titres courts + mode Expert plus robuste - Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings - Indexation notes courtes + test unit aligné
This commit is contained in:
@@ -1,58 +1,184 @@
|
||||
import { getUsers } from '@/app/actions/admin'
|
||||
import { AdminMetrics } from '@/components/admin-metrics'
|
||||
import { Users, Activity, Database, Zap } from 'lucide-react'
|
||||
import { Users, Activity, Database, Zap, CreditCard, Bot } from 'lucide-react'
|
||||
import prisma from '@/lib/prisma'
|
||||
import Link from 'next/link'
|
||||
|
||||
async function getAdminDashboardStats() {
|
||||
const now = new Date()
|
||||
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
|
||||
const [
|
||||
userCount,
|
||||
noteCount,
|
||||
notebookCount,
|
||||
agentsEnabled,
|
||||
agentRunsToday,
|
||||
paidSubs,
|
||||
recentUsers,
|
||||
] = await Promise.all([
|
||||
prisma.user.count().catch(() => 0),
|
||||
prisma.note.count({ where: { trashedAt: null } }).catch(() => 0),
|
||||
prisma.notebook.count().catch(() => 0),
|
||||
prisma.agent.count({ where: { isEnabled: true } }).catch(() => 0),
|
||||
prisma.agentAction.count({ where: { createdAt: { gte: dayAgo } } }).catch(() => 0),
|
||||
prisma.subscription.count({
|
||||
where: { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] }, status: 'ACTIVE' },
|
||||
}).catch(() => 0),
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: { id: true, name: true, email: true, createdAt: true, role: true },
|
||||
}).catch(() => []),
|
||||
])
|
||||
|
||||
// AI usage this month
|
||||
let aiRequestsMonth = 0
|
||||
try {
|
||||
aiRequestsMonth = await prisma.usageLog.aggregate({
|
||||
where: { periodStart: { gte: monthStart } },
|
||||
_sum: { requestsCount: true },
|
||||
}).then((r) => r._sum.requestsCount ?? 0)
|
||||
} catch {
|
||||
try {
|
||||
aiRequestsMonth = await prisma.agentAction.count({
|
||||
where: { createdAt: { gte: monthStart } },
|
||||
})
|
||||
} catch {
|
||||
aiRequestsMonth = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userCount,
|
||||
noteCount,
|
||||
notebookCount,
|
||||
agentsEnabled,
|
||||
agentRunsToday,
|
||||
paidSubs,
|
||||
aiRequestsMonth,
|
||||
recentUsers,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const users = await getUsers()
|
||||
const [users, stats] = await Promise.all([getUsers(), getAdminDashboardStats()])
|
||||
|
||||
// Mock metrics data - in a real app, these would come from analytics
|
||||
const metrics = [
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: users.length,
|
||||
trend: { value: 12, isPositive: true },
|
||||
title: 'Utilisateurs',
|
||||
value: stats.userCount,
|
||||
trend: undefined as { value: number; isPositive: boolean } | undefined,
|
||||
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
||||
},
|
||||
{
|
||||
title: 'Active Sessions',
|
||||
value: '24',
|
||||
trend: { value: 8, isPositive: true },
|
||||
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Total Notes',
|
||||
value: '1,234',
|
||||
trend: { value: 24, isPositive: true },
|
||||
title: 'Notes',
|
||||
value: stats.noteCount.toLocaleString('fr-FR'),
|
||||
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
|
||||
},
|
||||
{
|
||||
title: 'AI Requests',
|
||||
value: '856',
|
||||
trend: { value: 5, isPositive: false },
|
||||
title: 'Abonnements payants',
|
||||
value: stats.paidSubs,
|
||||
icon: <CreditCard className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Agents actifs',
|
||||
value: stats.agentsEnabled,
|
||||
icon: <Bot className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Exécutions agents (24h)',
|
||||
value: stats.agentRunsToday,
|
||||
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Usage IA (mois)',
|
||||
value: stats.aiRequestsMonth.toLocaleString('fr-FR'),
|
||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Overview of your application metrics
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Administration
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Vue d'ensemble en temps réel — {stats.notebookCount} carnets · {users.length} comptes listés
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/admin/billing"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Facturation & quotas
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/ai"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Config IA
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Utilisateurs
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Paramètres
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminMetrics metrics={metrics} />
|
||||
|
||||
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Recent activity will be displayed here.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
Derniers utilisateurs
|
||||
</h2>
|
||||
{stats.recentUsers.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Aucun utilisateur.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{stats.recentUsers.map((u) => (
|
||||
<li key={u.id} className="py-2.5 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{u.name || u.email}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{u.role}</span>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{u.createdAt ? new Date(u.createdAt).toLocaleDateString('fr-FR') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
Raccourcis ops
|
||||
</h2>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>· Configurer les Price IDs Stripe dans <Link href="/admin/billing" className="text-primary underline">Facturation</Link></li>
|
||||
<li>· Vérifier les quotas IA par feature (BASIC / PRO / BUSINESS)</li>
|
||||
<li>· Tester les providers dans <Link href="/admin/ai" className="text-primary underline">IA</Link></li>
|
||||
<li>· CRON_SECRET requis pour agents planifiés (entrypoint Docker)</li>
|
||||
<li>· Notes indexées via embeddings + fragments (y compris notes courtes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,21 +11,21 @@ export default function SettingsLayout({
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950">
|
||||
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
|
||||
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
aria-label={t('settings.title')}
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
|
||||
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 tracking-tight leading-none italic font-medium">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete dark:text-zinc-500 opacity-60 mt-4">
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -240,10 +240,10 @@ export async function runAgent(id: string) {
|
||||
return { success: false, agentId: id, error: 'Agent introuvable' }
|
||||
}
|
||||
|
||||
// Fire and forget — return immediately so the UI doesn't block
|
||||
import('@/lib/ai/services/agent-executor.service')
|
||||
.then(({ executeAgent }) => executeAgent(id, userId))
|
||||
.then(() => { /* revalidation is handled client-side via polling */ })
|
||||
// Load module first so import failures surface immediately (not silently after response)
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
|
||||
void executeAgent(id, userId)
|
||||
.catch(err => console.error('[runAgent] Background error:', err))
|
||||
|
||||
return { success: true, agentId: id, status: 'running' }
|
||||
@@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) {
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.agent.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
frequency: true,
|
||||
scheduledTime: true,
|
||||
scheduledDay: true,
|
||||
timezone: true,
|
||||
nextRun: true,
|
||||
},
|
||||
})
|
||||
if (!existing) throw new Error('Agent introuvable')
|
||||
|
||||
const data: { isEnabled: boolean; nextRun?: Date | null } = { isEnabled }
|
||||
|
||||
// When re-enabling a scheduled agent, ensure nextRun is set (was often null → never cron'd)
|
||||
if (isEnabled && existing.frequency !== 'manual' && existing.frequency !== 'one-shot') {
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: existing.frequency,
|
||||
scheduledTime: existing.scheduledTime,
|
||||
scheduledDay: existing.scheduledDay,
|
||||
timezone: existing.timezone,
|
||||
})
|
||||
data.nextRun = nextRun
|
||||
}
|
||||
|
||||
const agent = await prisma.agent.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { isEnabled }
|
||||
data,
|
||||
})
|
||||
return { success: true, agent }
|
||||
} catch (error) {
|
||||
|
||||
@@ -39,10 +39,18 @@ export async function POST(request: NextRequest) {
|
||||
{ level, count, language: language || 'fr' }
|
||||
)
|
||||
|
||||
// Prefer AI short title over raw user prompt (notebookName from client is often the full message)
|
||||
const resolvedName = (
|
||||
result.notebookName?.trim()
|
||||
|| (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '')
|
||||
|| result.notes[0]?.title?.trim()
|
||||
|| topic.trim().slice(0, 60)
|
||||
).slice(0, 80)
|
||||
|
||||
// 1. Create notebook
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: notebookName || topic,
|
||||
name: resolvedName,
|
||||
icon: notebookIcon || '📚',
|
||||
userId: session.user.id,
|
||||
order: 0,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
|
||||
const bodySchema = z.object({
|
||||
tier: z.enum(['PRO', 'BUSINESS']),
|
||||
interval: z.enum(['month', 'year']),
|
||||
/** Prefer hosted redirect when embedded checkout is unavailable */
|
||||
mode: z.enum(['hosted', 'embedded']).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -16,17 +18,46 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!(await isBillingEnabled())) {
|
||||
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const secret = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secret || secret === 'sk_test_placeholder') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(await req.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { tier, interval } = parsed.data;
|
||||
const preferredMode = parsed.data.mode ?? 'hosted';
|
||||
const userId = session.user.id;
|
||||
const userEmail = session.user.email;
|
||||
|
||||
try {
|
||||
const priceId = await resolvePriceId(tier, interval);
|
||||
let priceId: string;
|
||||
try {
|
||||
priceId = await resolvePriceId(tier, interval);
|
||||
} catch (e) {
|
||||
console.error('[billing/create-checkout] price resolve failed:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (priceId.startsWith('price_mock_')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
let customerId = subscription?.stripeCustomerId ?? undefined;
|
||||
@@ -41,8 +72,7 @@ export async function POST(req: NextRequest) {
|
||||
metadata: { userId },
|
||||
});
|
||||
customerId = customer.id;
|
||||
|
||||
// Update DB to save the real Stripe customer ID
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: { stripeCustomerId: customerId },
|
||||
@@ -52,8 +82,8 @@ export async function POST(req: NextRequest) {
|
||||
tier: 'BASIC',
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
|
||||
}
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,29 +91,51 @@ export async function POST(req: NextRequest) {
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
||||
const origin = `${proto}://${host}`;
|
||||
|
||||
const sessionParams = {
|
||||
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
|
||||
if (preferredMode === 'embedded') {
|
||||
try {
|
||||
const embedded = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
ui_mode: 'embedded' as any,
|
||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
} as any);
|
||||
if (embedded.client_secret) {
|
||||
return NextResponse.json({
|
||||
clientSecret: embedded.client_secret,
|
||||
sessionId: embedded.id,
|
||||
});
|
||||
}
|
||||
} catch (embeddedErr) {
|
||||
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
|
||||
}
|
||||
}
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription' as const,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
ui_mode: 'embedded_page',
|
||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/settings/billing?canceled=1`,
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
|
||||
});
|
||||
|
||||
if (checkoutSession.client_secret) {
|
||||
return NextResponse.json({
|
||||
clientSecret: checkoutSession.client_secret,
|
||||
sessionId: checkoutSession.id,
|
||||
});
|
||||
if (!checkoutSession.url) {
|
||||
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
|
||||
} catch (error) {
|
||||
console.error('[billing/create-checkout]', error);
|
||||
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
// Due = nextRun in the past; also backfill agents whose nextRun was never set
|
||||
// (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls
|
||||
const dueAgents = await prisma.agent.findMany({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
frequency: { not: 'manual' },
|
||||
frequency: { notIn: ['manual', 'one-shot'] },
|
||||
nextRun: { lte: now },
|
||||
},
|
||||
select: {
|
||||
@@ -40,13 +42,44 @@ export async function POST(request: NextRequest) {
|
||||
scheduledTime: true,
|
||||
scheduledDay: true,
|
||||
timezone: true,
|
||||
nextRun: true,
|
||||
},
|
||||
orderBy: { nextRun: 'asc' },
|
||||
})
|
||||
|
||||
if (dueAgents.length === 0) {
|
||||
return NextResponse.json({ success: true, executed: 0 })
|
||||
// One-time repair: set nextRun for enabled scheduled agents stuck with null
|
||||
const unscheduled = await prisma.agent.findMany({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
frequency: { notIn: ['manual', 'one-shot'] },
|
||||
nextRun: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
frequency: true,
|
||||
scheduledTime: true,
|
||||
scheduledDay: true,
|
||||
timezone: true,
|
||||
},
|
||||
take: 50,
|
||||
})
|
||||
for (const a of unscheduled) {
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: a.frequency,
|
||||
scheduledTime: a.scheduledTime,
|
||||
scheduledDay: a.scheduledDay,
|
||||
timezone: a.timezone,
|
||||
})
|
||||
await prisma.agent.update({ where: { id: a.id }, data: { nextRun } })
|
||||
}
|
||||
|
||||
if (dueAgents.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
executed: 0,
|
||||
repairedSchedules: unscheduled.length,
|
||||
})
|
||||
}
|
||||
|
||||
const results: { id: string; success: boolean; error?: string }[] = []
|
||||
|
||||
@@ -56,7 +89,6 @@ export async function POST(request: NextRequest) {
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
const result = await executeAgent(agent.id, agent.userId)
|
||||
|
||||
// Calculate and set next run
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: agent.frequency,
|
||||
scheduledTime: agent.scheduledTime,
|
||||
@@ -64,7 +96,6 @@ export async function POST(request: NextRequest) {
|
||||
timezone: agent.timezone,
|
||||
})
|
||||
|
||||
|
||||
await prisma.agent.update({
|
||||
where: { id: agent.id },
|
||||
data: { nextRun },
|
||||
@@ -75,7 +106,6 @@ export async function POST(request: NextRequest) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
|
||||
|
||||
// Still schedule next run even on failure
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: agent.frequency,
|
||||
scheduledTime: agent.scheduledTime,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { auth } from '@/auth'
|
||||
import { writeUploadMeta } from '@/lib/upload-access'
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
@@ -14,14 +15,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file uploaded' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
@@ -33,7 +32,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
// Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes)
|
||||
let ext = path.extname(file.name).toLowerCase()
|
||||
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
|
||||
const mimeToExt: Record<string, string> = {
|
||||
@@ -44,23 +42,28 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
ext = mimeToExt[file.type] || '.png'
|
||||
}
|
||||
|
||||
const filename = `${randomUUID()}${ext}`
|
||||
|
||||
// Ensure directory exists (data/uploads is outside public/, served via API route)
|
||||
const uploadDir = path.join(process.cwd(), 'data/uploads/notes')
|
||||
// Ownership in path: /uploads/notes/{userId}/{filename}
|
||||
const relativeUnderNotes = `${userId}/${filename}`
|
||||
const uploadDir = path.join(process.cwd(), 'data', 'uploads', 'notes', userId)
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
|
||||
|
||||
const filePath = path.join(uploadDir, filename)
|
||||
await writeFile(filePath, buffer)
|
||||
await writeUploadMeta(relativeUnderNotes, userId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: `/uploads/notes/${filename}`
|
||||
const url = `/uploads/notes/${relativeUnderNotes}`
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[upload]', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload file' },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,30 @@ export async function GET(
|
||||
const session = await auth()
|
||||
const { path: segments } = await params
|
||||
|
||||
// Only serve from uploads/notes/ subdirectory
|
||||
if (segments[0] !== 'notes') {
|
||||
// Only serve from uploads/notes/ (flat or userId/file)
|
||||
if (!segments.length || segments[0] !== 'notes') {
|
||||
return new NextResponse('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Reject path traversal
|
||||
if (segments.some((s) => s === '..' || s.includes('\0'))) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const filename = segments[segments.length - 1]
|
||||
const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id)
|
||||
// Never serve meta sidecars as images
|
||||
if (filename.endsWith('.meta.json')) {
|
||||
return new NextResponse('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const allowed = await canAccessUploadedNoteImage(segments, session?.user?.id)
|
||||
if (!allowed) {
|
||||
return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', {
|
||||
status: session?.user?.id ? 403 : 401,
|
||||
headers: {
|
||||
// Do not let browsers / CDNs cache a denial (would stick a broken image)
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,9 +54,8 @@ export async function GET(
|
||||
return new NextResponse('Unsupported file type', { status: 400 })
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
const safePath = path.join(UPLOAD_DIR, ...segments)
|
||||
if (!safePath.startsWith(UPLOAD_DIR)) {
|
||||
if (!safePath.startsWith(UPLOAD_DIR + path.sep) && safePath !== UPLOAD_DIR) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
@@ -57,7 +70,8 @@ export async function GET(
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
// private: only for authenticated session fetches; still long cache after auth ok
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Length': String(buffer.length),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1623,7 +1623,11 @@ html.font-system * {
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
transition: filter 0.15s ease;
|
||||
transition: filter 0.15s ease, outline 0.15s ease;
|
||||
cursor: pointer;
|
||||
/* Ensure clicks select the node (not swallowed by surrounding layout) */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.notion-editor-wrapper .ProseMirror img:hover {
|
||||
@@ -1635,6 +1639,14 @@ html.font-system * {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Image node wrapper when selected as block */
|
||||
.notion-editor-wrapper .ProseMirror .ProseMirror-selectednode img,
|
||||
.notion-editor-wrapper .ProseMirror img.ProseMirror-selectednode {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
}
|
||||
|
||||
/* --- Links --- */
|
||||
.notion-editor-wrapper .ProseMirror a {
|
||||
color: var(--primary);
|
||||
|
||||
Reference in New Issue
Block a user