Persist synastry report history in Supabase
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
type SynastryReportPayload = {
|
||||
id?: string;
|
||||
partnerName?: string;
|
||||
report?: unknown;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("synastry_reports")
|
||||
.select("id, partner_name, report, created_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ reports: data ?? [] });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "合盘历史暂时不可用") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => null) as SynastryReportPayload | null;
|
||||
if (!body?.report || typeof body.report !== "object" || Array.isArray(body.report)) {
|
||||
return NextResponse.json({ error: "合盘报告格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const partnerName = body.partnerName?.trim() || "对方";
|
||||
const record = {
|
||||
...(body.id ? { id: body.id } : {}),
|
||||
user_id: user.id,
|
||||
partner_name: partnerName,
|
||||
report: body.report,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("synastry_reports")
|
||||
.insert(record)
|
||||
.select("id, partner_name, report, created_at")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ report: data });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "合盘历史保存失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@ type SynastryReportCard = {
|
||||
nextEvidence?: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
type SynastryReportApiRecord = {
|
||||
id: string;
|
||||
partner_name?: string;
|
||||
report?: SynastryReportCard;
|
||||
created_at?: string;
|
||||
};
|
||||
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number };
|
||||
type RequestError = { sessionId: string; message: string };
|
||||
type StreamingReply = { sessionId: string; text: string };
|
||||
@@ -229,6 +235,38 @@ function readSynastryHistory(accountId: string): SynastryReportCard[] {
|
||||
}
|
||||
}
|
||||
|
||||
function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) {
|
||||
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10)));
|
||||
}
|
||||
|
||||
function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null {
|
||||
if (!record.report || typeof record.report !== "object") return null;
|
||||
return {
|
||||
...record.report,
|
||||
id: record.id,
|
||||
partnerName: record.partner_name || record.report.partnerName || "对方",
|
||||
createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCloudSynastryHistory() {
|
||||
const response = await fetch("/api/synastry-reports", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("cloud_synastry_history_unavailable");
|
||||
const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null;
|
||||
return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[];
|
||||
}
|
||||
|
||||
async function saveCloudSynastryReport(report: SynastryReportCard) {
|
||||
const response = await fetch("/api/synastry-reports", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ partnerName: report.partnerName, report }),
|
||||
});
|
||||
if (!response.ok) throw new Error("cloud_synastry_report_save_failed");
|
||||
const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null;
|
||||
return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report;
|
||||
}
|
||||
|
||||
function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord {
|
||||
return {
|
||||
id: record.role === "self" ? "self" : record.id,
|
||||
@@ -636,6 +674,18 @@ export default function Home() {
|
||||
.catch(() => {
|
||||
// Cloud chart library is best-effort; local library remains usable.
|
||||
});
|
||||
void fetchCloudSynastryHistory()
|
||||
.then((cloudHistory) => {
|
||||
setSynastryHistory((current) => {
|
||||
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
|
||||
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
|
||||
writeSynastryHistory(accountId, next);
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Cloud synastry history is best-effort; local history remains usable.
|
||||
});
|
||||
}, [accountId, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1303,11 +1353,19 @@ export default function Home() {
|
||||
nextEvidence: payload.relationshipReport?.nextEvidence,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
setSynastryReportCard(reportCard);
|
||||
let savedReportCard = reportCard;
|
||||
if (accountId) {
|
||||
try {
|
||||
savedReportCard = await saveCloudSynastryReport(reportCard);
|
||||
} catch {
|
||||
// Local history remains the fallback when cloud persistence is unavailable.
|
||||
}
|
||||
}
|
||||
setSynastryReportCard(savedReportCard);
|
||||
if (accountId) {
|
||||
setSynastryHistory((current) => {
|
||||
const next = [reportCard, ...current].slice(0, 10);
|
||||
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(next));
|
||||
const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10);
|
||||
writeSynastryHistory(accountId, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
begin;
|
||||
|
||||
create table if not exists public.synastry_reports (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
partner_name text not null default '对方',
|
||||
report jsonb not null check (jsonb_typeof(report) = 'object'),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists synastry_reports_user_created_at_idx
|
||||
on public.synastry_reports (user_id, created_at desc);
|
||||
|
||||
alter table public.synastry_reports enable row level security;
|
||||
|
||||
drop policy if exists synastry_reports_select_own on public.synastry_reports;
|
||||
create policy synastry_reports_select_own
|
||||
on public.synastry_reports for select to authenticated
|
||||
using ((select auth.uid()) = user_id);
|
||||
|
||||
drop policy if exists synastry_reports_insert_own on public.synastry_reports;
|
||||
create policy synastry_reports_insert_own
|
||||
on public.synastry_reports for insert to authenticated
|
||||
with check ((select auth.uid()) = user_id);
|
||||
|
||||
drop policy if exists synastry_reports_delete_own on public.synastry_reports;
|
||||
create policy synastry_reports_delete_own
|
||||
on public.synastry_reports for delete to authenticated
|
||||
using ((select auth.uid()) = user_id);
|
||||
|
||||
revoke all on table public.synastry_reports from anon, authenticated, service_role;
|
||||
grant select on table public.synastry_reports to authenticated;
|
||||
grant insert (id, user_id, partner_name, report, created_at) on table public.synastry_reports to authenticated;
|
||||
grant delete on table public.synastry_reports to authenticated;
|
||||
|
||||
commit;
|
||||
@@ -30,10 +30,18 @@ CHART_PROFILE_MIGRATION = (
|
||||
/ "migrations"
|
||||
/ "20260717010000_chart_profiles.sql"
|
||||
)
|
||||
SYNASTRY_REPORT_MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "frontend"
|
||||
/ "supabase"
|
||||
/ "migrations"
|
||||
/ "20260717020000_synastry_reports.sql"
|
||||
)
|
||||
PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx"
|
||||
CHART_PROFILE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "route.ts"
|
||||
CHART_PROFILE_DELETE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "[id]" / "route.ts"
|
||||
SYNASTRY_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry" / "route.ts"
|
||||
SYNASTRY_REPORT_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry-reports" / "route.ts"
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
@@ -183,6 +191,46 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
|
||||
assert token in route
|
||||
|
||||
|
||||
def test_synastry_reports_are_cloud_persisted_per_user() -> None:
|
||||
sql = re.sub(r"\s+", " ", SYNASTRY_REPORT_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||||
route = SYNASTRY_REPORT_ROUTE.read_text(encoding="utf-8")
|
||||
page = PAGE.read_text(encoding="utf-8")
|
||||
|
||||
for token in (
|
||||
"create table if not exists public.synastry_reports",
|
||||
"user_id uuid not null references auth.users(id) on delete cascade",
|
||||
"partner_name text not null default '对方'",
|
||||
"report jsonb not null check (jsonb_typeof(report) = 'object')",
|
||||
"alter table public.synastry_reports enable row level security",
|
||||
"create policy synastry_reports_select_own on public.synastry_reports for select to authenticated using ((select auth.uid()) = user_id)",
|
||||
"create policy synastry_reports_insert_own on public.synastry_reports for insert to authenticated with check ((select auth.uid()) = user_id)",
|
||||
"create policy synastry_reports_delete_own on public.synastry_reports for delete to authenticated using ((select auth.uid()) = user_id)",
|
||||
"grant select on table public.synastry_reports to authenticated",
|
||||
"grant insert (id, user_id, partner_name, report, created_at) on table public.synastry_reports to authenticated",
|
||||
):
|
||||
assert token in sql
|
||||
|
||||
for token in (
|
||||
'.from("synastry_reports")',
|
||||
'.select("id, partner_name, report, created_at")',
|
||||
".eq(\"user_id\", user.id)",
|
||||
".limit(10)",
|
||||
"partner_name: partnerName",
|
||||
"report: body.report",
|
||||
):
|
||||
assert token in route
|
||||
|
||||
for token in (
|
||||
"fetchCloudSynastryHistory",
|
||||
"saveCloudSynastryReport",
|
||||
'fetch("/api/synastry-reports"',
|
||||
"writeSynastryHistory(accountId, next)",
|
||||
"cloud_synastry_history_unavailable",
|
||||
"cloud_synastry_report_save_failed",
|
||||
):
|
||||
assert token in page
|
||||
|
||||
|
||||
def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None:
|
||||
sql = re.sub(r"\s+", " ", CONSULTATION_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user