Build AI Chief of Staff MVP: auth, mailbox sync, briefings, draft replies
Scaffolds a Next.js app with email/password auth, encrypted IMAP/SMTP/CalDAV mailbox connections, a background sync job, Claude-generated daily briefings, and an approve-before-send draft reply flow. Includes a clean design system (light/dark) and deployment config (systemd unit, credential rotation script). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
1 parent
facd4d71bd
commit
bcf0e86618
65 files changed
+13413
-1
No files matched your search
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function GenerateBriefingButton() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onClick() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/briefing/generate", { method: "POST" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPending(false);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to generate briefing");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field" style={{ marginBottom: 24 }}>
|
||||
<button onClick={onClick} className="btn btn-primary" disabled={pending}>
|
||||
{pending ? "Generating…" : "Generate today's briefing"}
|
||||
</button>
|
||||
{error && <div className="alert alert-danger" style={{ marginTop: 10 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import type { BriefingResult } from "@/lib/ai/briefing";
|
||||
import { GenerateBriefingButton } from "./generate-button";
|
||||
|
||||
const PRIORITY_BADGE: Record<string, string> = {
|
||||
high: "badge-danger",
|
||||
medium: "badge-warning",
|
||||
low: "badge-neutral",
|
||||
};
|
||||
|
||||
export default async function BriefingPage() {
|
||||
const session = await getCurrentSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const briefing = await db.query.briefings.findFirst({
|
||||
where: (b, { eq }) => eq(b.userId, session.user.id),
|
||||
orderBy: (b, { desc }) => [desc(b.createdAt)],
|
||||
});
|
||||
|
||||
const items: BriefingResult["items"] = briefing ? JSON.parse(briefing.itemsJson) : [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-title">Today's briefing</div>
|
||||
<p className="page-subtitle">A prioritized summary of what needs your attention.</p>
|
||||
|
||||
<GenerateBriefingButton />
|
||||
|
||||
{!briefing && (
|
||||
<div className="empty-state card">
|
||||
No briefing generated yet — connect a mailbox, sync, then generate one.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{briefing && (
|
||||
<>
|
||||
<div className="card" style={{ marginBottom: 20 }}>
|
||||
<p style={{ fontSize: 15 }}>{briefing.overallSummary}</p>
|
||||
<p className="faint" style={{ marginTop: 10 }}>
|
||||
Generated {briefing.createdAt.toLocaleString()} · {briefing.briefingDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="stack">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="briefing-item">
|
||||
<div className="briefing-item-head">
|
||||
<span className="briefing-item-category">{item.category}</span>
|
||||
<span className={`badge ${PRIORITY_BADGE[item.priority] ?? "badge-neutral"}`}>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted" style={{ color: "var(--text)" }}>{item.summary}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export interface DraftCardData {
|
||||
id: string;
|
||||
generatedSubject: string;
|
||||
generatedBody: string;
|
||||
editedBody: string | null;
|
||||
status: string;
|
||||
sourceEmail: {
|
||||
fromAddress: string | null;
|
||||
subject: string | null;
|
||||
snippet: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function DraftCard({ draft }: { draft: DraftCardData }) {
|
||||
const router = useRouter();
|
||||
const [subject, setSubject] = useState(draft.generatedSubject);
|
||||
const [body, setBody] = useState(draft.editedBody ?? draft.generatedBody);
|
||||
const [pending, setPending] = useState<"send" | "reject" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSend() {
|
||||
setPending("send");
|
||||
setError(null);
|
||||
const res = await fetch(`/api/drafts/${draft.id}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subject, body }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPending(null);
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to send");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function onReject() {
|
||||
setPending("reject");
|
||||
setError(null);
|
||||
const res = await fetch(`/api/drafts/${draft.id}/reject`, { method: "POST" });
|
||||
setPending(null);
|
||||
if (!res.ok) {
|
||||
setError("Failed to reject");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
{draft.sourceEmail && (
|
||||
<div className="card-section" style={{ borderTop: "none", paddingTop: 0, marginTop: 0, marginBottom: 16 }}>
|
||||
<p className="muted">
|
||||
Replying to <strong style={{ color: "var(--text)" }}>{draft.sourceEmail.fromAddress}</strong>
|
||||
{" — "}“{draft.sourceEmail.subject}”
|
||||
</p>
|
||||
{draft.sourceEmail.snippet && <p className="faint" style={{ marginTop: 4 }}>{draft.sourceEmail.snippet}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Subject</label>
|
||||
<input value={subject} onChange={(e) => setSubject(e.target.value)} className="input" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Body</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={8}
|
||||
className="textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={onSend} className="btn btn-primary" disabled={pending !== null}>
|
||||
{pending === "send" ? "Sending…" : "Send"}
|
||||
</button>
|
||||
<button onClick={onReject} className="btn" disabled={pending !== null}>
|
||||
{pending === "reject" ? "Rejecting…" : "Reject"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { emailDrafts, emails } from "@/lib/db/schema";
|
||||
import { DraftCard } from "./draft-card";
|
||||
|
||||
export default async function DraftsPage() {
|
||||
const session = await getCurrentSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const rows = await db
|
||||
.select({ draft: emailDrafts, email: emails })
|
||||
.from(emailDrafts)
|
||||
.leftJoin(emails, eq(emailDrafts.sourceEmailId, emails.id))
|
||||
.where(and(eq(emailDrafts.userId, session.user.id), inArray(emailDrafts.status, ["pending", "edited"])))
|
||||
.orderBy(desc(emailDrafts.createdAt));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-title">Draft replies</div>
|
||||
<p className="page-subtitle">Review and edit before anything is sent — nothing goes out automatically.</p>
|
||||
|
||||
{rows.length === 0 && (
|
||||
<div className="empty-state card">
|
||||
No pending drafts. Generate one from an email on the <a href="/sync" style={{ color: "var(--accent)", fontWeight: 500 }}>sync debug page</a>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stack">
|
||||
{rows.map(({ draft, email }) => (
|
||||
<DraftCard
|
||||
key={draft.id}
|
||||
draft={{
|
||||
id: draft.id,
|
||||
generatedSubject: draft.generatedSubject,
|
||||
generatedBody: draft.generatedBody,
|
||||
editedBody: draft.editedBody,
|
||||
status: draft.status,
|
||||
sourceEmail: email
|
||||
? { fromAddress: email.fromAddress, subject: email.subject, snippet: email.snippet }
|
||||
: null,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
import { NavLinks } from "./nav-links";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getCurrentSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", minHeight: "100%", flex: 1 }}>
|
||||
<header className="topnav">
|
||||
<div className="topnav-inner">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" />
|
||||
Chief of Staff
|
||||
</div>
|
||||
<NavLinks />
|
||||
<div className="user-menu">
|
||||
<span className="user-email">{session.user.email}</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function onClick() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className="btn btn-subtle btn-sm">
|
||||
Log out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/briefing", label: "Briefing" },
|
||||
{ href: "/drafts", label: "Drafts" },
|
||||
{ href: "/settings/mailbox", label: "Mailbox" },
|
||||
{ href: "/sync", label: "Sync debug" },
|
||||
];
|
||||
|
||||
export function NavLinks() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="nav-links">
|
||||
{LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`nav-link${pathname === link.href ? " active" : ""}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type ProtocolResult = { ok: true } | { ok: false; reason: string; message: string };
|
||||
|
||||
interface ConnectResponse {
|
||||
results?: {
|
||||
imap?: ProtocolResult;
|
||||
smtp?: ProtocolResult;
|
||||
caldav?: ProtocolResult;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
connected: boolean;
|
||||
imap?: { host: string; user: string } | null;
|
||||
smtp?: { host: string; user: string } | null;
|
||||
caldav?: { url: string; user: string } | null;
|
||||
}
|
||||
|
||||
function ResultBadge({ result }: { result?: ProtocolResult }) {
|
||||
if (!result) return null;
|
||||
if (result.ok) return <span className="badge badge-success">Connected</span>;
|
||||
return (
|
||||
<div className="alert alert-danger" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{result.reason}: {result.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MailboxSettingsPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [results, setResults] = useState<ConnectResponse["results"]>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [imapEnabled, setImapEnabled] = useState(true);
|
||||
const [imap, setImap] = useState({ host: "", port: "993", secure: true, user: "", pass: "" });
|
||||
|
||||
const [smtpEnabled, setSmtpEnabled] = useState(true);
|
||||
const [smtp, setSmtp] = useState({ host: "", port: "465", secure: true, user: "", pass: "" });
|
||||
|
||||
const [caldavEnabled, setCaldavEnabled] = useState(false);
|
||||
const [caldav, setCaldav] = useState({ serverUrl: "", username: "", password: "" });
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/mailbox/connect")
|
||||
.then((r) => r.json())
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setResults(undefined);
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (imapEnabled) body.imap = { ...imap, port: Number(imap.port) };
|
||||
if (smtpEnabled) body.smtp = { ...smtp, port: Number(smtp.port) };
|
||||
if (caldavEnabled) body.caldav = caldav;
|
||||
|
||||
const res = await fetch("/api/mailbox/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data: ConnectResponse = await res.json();
|
||||
setSubmitting(false);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Request failed");
|
||||
return;
|
||||
}
|
||||
setResults(data.results);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-title">Connect your mailbox</div>
|
||||
<p className="page-subtitle">
|
||||
Each protocol is tested before saving — you can save whichever ones succeed and retry the
|
||||
rest later.
|
||||
</p>
|
||||
|
||||
{status?.connected && (
|
||||
<div className="alert alert-info stack" style={{ gap: 4 }}>
|
||||
<strong>Currently saved</strong>
|
||||
{status.imap && <div>IMAP: {status.imap.user}@{status.imap.host}</div>}
|
||||
{status.smtp && <div>SMTP: {status.smtp.user}@{status.smtp.host}</div>}
|
||||
{status.caldav && <div>CalDAV: {status.caldav.user} @ {status.caldav.url}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="card">
|
||||
<div className="card-section">
|
||||
<div className="card-section-title">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imapEnabled}
|
||||
onChange={(e) => setImapEnabled(e.target.checked)}
|
||||
/>
|
||||
IMAP
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Host</label>
|
||||
<input
|
||||
placeholder="imap.example.com"
|
||||
value={imap.host}
|
||||
onChange={(e) => setImap({ ...imap, host: e.target.value })}
|
||||
disabled={!imapEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Port</label>
|
||||
<input
|
||||
value={imap.port}
|
||||
onChange={(e) => setImap({ ...imap, port: e.target.value })}
|
||||
disabled={!imapEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<label className="checkbox-row" style={{ marginBottom: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imap.secure}
|
||||
onChange={(e) => setImap({ ...imap, secure: e.target.checked })}
|
||||
disabled={!imapEnabled}
|
||||
/>
|
||||
Use TLS
|
||||
</label>
|
||||
<div className="field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
value={imap.user}
|
||||
onChange={(e) => setImap({ ...imap, user: e.target.value })}
|
||||
disabled={!imapEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={imap.pass}
|
||||
onChange={(e) => setImap({ ...imap, pass: e.target.value })}
|
||||
disabled={!imapEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<ResultBadge result={results?.imap} />
|
||||
</div>
|
||||
|
||||
<div className="card-section">
|
||||
<div className="card-section-title">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={smtpEnabled}
|
||||
onChange={(e) => setSmtpEnabled(e.target.checked)}
|
||||
/>
|
||||
SMTP
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Host</label>
|
||||
<input
|
||||
placeholder="smtp.example.com"
|
||||
value={smtp.host}
|
||||
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
|
||||
disabled={!smtpEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Port</label>
|
||||
<input
|
||||
value={smtp.port}
|
||||
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
|
||||
disabled={!smtpEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<label className="checkbox-row" style={{ marginBottom: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={smtp.secure}
|
||||
onChange={(e) => setSmtp({ ...smtp, secure: e.target.checked })}
|
||||
disabled={!smtpEnabled}
|
||||
/>
|
||||
Use TLS
|
||||
</label>
|
||||
<div className="field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
value={smtp.user}
|
||||
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
|
||||
disabled={!smtpEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={smtp.pass}
|
||||
onChange={(e) => setSmtp({ ...smtp, pass: e.target.value })}
|
||||
disabled={!smtpEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<ResultBadge result={results?.smtp} />
|
||||
</div>
|
||||
|
||||
<div className="card-section">
|
||||
<div className="card-section-title">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={caldavEnabled}
|
||||
onChange={(e) => setCaldavEnabled(e.target.checked)}
|
||||
/>
|
||||
CalDAV <span className="faint">(optional)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Server URL</label>
|
||||
<input
|
||||
value={caldav.serverUrl}
|
||||
onChange={(e) => setCaldav({ ...caldav, serverUrl: e.target.value })}
|
||||
disabled={!caldavEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
value={caldav.username}
|
||||
onChange={(e) => setCaldav({ ...caldav, username: e.target.value })}
|
||||
disabled={!caldavEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={caldav.password}
|
||||
onChange={(e) => setCaldav({ ...caldav, password: e.target.value })}
|
||||
disabled={!caldavEnabled}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<ResultBadge result={results?.caldav} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-danger" style={{ marginTop: 16 }}>{error}</div>}
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting} style={{ marginTop: 20 }}>
|
||||
{submitting ? "Testing connections…" : "Test & save"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function DraftReplyButton({ emailId }: { emailId: string }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onClick() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/drafts/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emailId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPending(false);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed");
|
||||
return;
|
||||
}
|
||||
router.push("/drafts");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onClick} className="btn btn-sm" disabled={pending}>
|
||||
{pending ? "Drafting…" : "Draft reply"}
|
||||
</button>
|
||||
{error && <div className="faint" style={{ color: "var(--danger)", marginTop: 4 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { emails, calendarEvents, syncLog } from "@/lib/db/schema";
|
||||
import { TriggerSyncButton } from "./trigger-button";
|
||||
import { DraftReplyButton } from "./draft-reply-button";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
succeeded: "badge-success",
|
||||
failed: "badge-danger",
|
||||
running: "badge-accent",
|
||||
};
|
||||
|
||||
export default async function SyncDebugPage() {
|
||||
const session = await getCurrentSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
const [recentEmails, recentEvents, recentLogs] = await Promise.all([
|
||||
db.select().from(emails).where(eq(emails.userId, userId)).orderBy(desc(emails.dateReceived)).limit(50),
|
||||
db
|
||||
.select()
|
||||
.from(calendarEvents)
|
||||
.where(eq(calendarEvents.userId, userId))
|
||||
.orderBy(desc(calendarEvents.startTime))
|
||||
.limit(50),
|
||||
db.select().from(syncLog).where(eq(syncLog.userId, userId)).orderBy(desc(syncLog.startedAt)).limit(10),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 960 }}>
|
||||
<div className="page-title">Sync debug</div>
|
||||
<p className="page-subtitle">Raw view of what's been fetched, for testing.</p>
|
||||
|
||||
<TriggerSyncButton />
|
||||
|
||||
<div className="stack" style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
<div className="card-section-title" style={{ marginTop: 24 }}>Recent sync runs</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentLogs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="muted">No sync runs yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
{recentLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="muted">{log.startedAt.toLocaleString()}</td>
|
||||
<td>
|
||||
<span className={`badge ${STATUS_BADGE[log.status] ?? "badge-neutral"}`}>{log.status}</span>
|
||||
</td>
|
||||
<td className="muted">{log.error ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="card-section-title" style={{ marginTop: 16 }}>Emails ({recentEmails.length})</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>From</th>
|
||||
<th>Subject</th>
|
||||
<th>Unread</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentEmails.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted">No emails synced yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
{recentEmails.map((email) => (
|
||||
<tr key={email.id}>
|
||||
<td className="muted">{email.dateReceived?.toLocaleString() ?? ""}</td>
|
||||
<td>{email.fromAddress}</td>
|
||||
<td>{email.subject}</td>
|
||||
<td>
|
||||
{email.isUnread ? (
|
||||
<span className="badge badge-accent">Unread</span>
|
||||
) : (
|
||||
<span className="faint">read</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<DraftReplyButton emailId={email.id} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="card-section-title" style={{ marginTop: 16 }}>Calendar events ({recentEvents.length})</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentEvents.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="muted">No calendar events synced yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
{recentEvents.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="muted">{event.startTime?.toLocaleString() ?? ""}</td>
|
||||
<td className="muted">{event.endTime?.toLocaleString() ?? ""}</td>
|
||||
<td>{event.summary}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function TriggerSyncButton() {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onClick() {
|
||||
setPending(true);
|
||||
setStatus(null);
|
||||
const res = await fetch("/api/cron/trigger", { method: "POST" });
|
||||
const data = await res.json();
|
||||
setPending(false);
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus(`Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
const r = data.result;
|
||||
setStatus(
|
||||
r.skipped
|
||||
? "A sync was already running — skipped."
|
||||
: r.error
|
||||
? `Failed: ${r.error}`
|
||||
: `Synced ${r.newEmails} new email(s), ${r.calendarEvents} calendar event(s).`,
|
||||
);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field" style={{ marginBottom: 8 }}>
|
||||
<button onClick={onClick} className="btn btn-primary" disabled={pending}>
|
||||
{pending ? "Syncing…" : "Trigger sync now"}
|
||||
</button>
|
||||
{status && <p className="muted" style={{ marginTop: 8 }}>{status}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user