Sync chart library profiles to Supabase
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.delete()
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "other");
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "星盘删除失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
type ChartProfilePayload = {
|
||||
id?: string;
|
||||
role?: "self" | "other";
|
||||
profile?: 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("chart_profiles")
|
||||
.select("id, role, profile, updated_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ profiles: 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 supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null) as ChartProfilePayload | null;
|
||||
if (!body?.profile || typeof body.profile !== "object") {
|
||||
return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 });
|
||||
}
|
||||
const role = body.role === "self" ? "self" : "other";
|
||||
const updatedAt = new Date().toISOString();
|
||||
let data;
|
||||
let error;
|
||||
if (role === "self") {
|
||||
const existing = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id")
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "self")
|
||||
.maybeSingle();
|
||||
if (existing.error) throw existing.error;
|
||||
const query = existing.data?.id
|
||||
? supabase
|
||||
.from("chart_profiles")
|
||||
.update({ profile: body.profile, updated_at: updatedAt })
|
||||
.eq("id", existing.data.id)
|
||||
.select("id, role, profile, updated_at")
|
||||
.single()
|
||||
: supabase
|
||||
.from("chart_profiles")
|
||||
.insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt })
|
||||
.select("id, role, profile, updated_at")
|
||||
.single();
|
||||
({ data, error } = await query);
|
||||
} else {
|
||||
const record = {
|
||||
...(body.id ? { id: body.id } : {}),
|
||||
user_id: user.id,
|
||||
role,
|
||||
profile: body.profile,
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
({ data, error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.upsert(record, { onConflict: "id" })
|
||||
.select("id, role, profile, updated_at")
|
||||
.single());
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ profile: 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 });
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ type ChartLibraryRecord = {
|
||||
profile: Profile;
|
||||
updatedAt: number;
|
||||
};
|
||||
type ChartLibraryApiRecord = {
|
||||
id: string;
|
||||
role: "self" | "other";
|
||||
profile: Profile;
|
||||
updated_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 };
|
||||
@@ -199,6 +205,42 @@ function readChartLibrary(accountId: string): ChartLibraryRecord[] {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord {
|
||||
return {
|
||||
id: record.role === "self" ? "self" : record.id,
|
||||
role: record.role,
|
||||
profile: record.profile,
|
||||
updatedAt: Date.parse(record.updated_at || "") || timestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCloudChartLibrary() {
|
||||
const response = await fetch("/api/chart-profiles", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("cloud_chart_library_unavailable");
|
||||
const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null;
|
||||
return (payload?.profiles || []).map(normalizeChartLibraryApiRecord);
|
||||
}
|
||||
|
||||
async function saveCloudChartProfile(record: ChartLibraryRecord) {
|
||||
const response = await fetch("/api/chart-profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: record.role === "self" ? undefined : record.id,
|
||||
role: record.role,
|
||||
profile: record.profile,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("cloud_chart_profile_save_failed");
|
||||
const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord } | null;
|
||||
return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record;
|
||||
}
|
||||
|
||||
async function deleteCloudChartProfile(recordId: string) {
|
||||
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("cloud_chart_profile_delete_failed");
|
||||
}
|
||||
|
||||
function profilePlaceLabel(profile: Profile) {
|
||||
return selectedBirthPlace(profile)?.label || "地点未完整";
|
||||
}
|
||||
@@ -518,6 +560,7 @@ export default function Home() {
|
||||
const modelSyncFailures = useRef(new Set<string>());
|
||||
const modelSelectionVersions = useRef(new Map<string, number>());
|
||||
const activeSessionIdRef = useRef("");
|
||||
const chartLibraryLoadedAccount = useRef("");
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
|
||||
@@ -535,9 +578,27 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
setChartLibrary([]);
|
||||
chartLibraryLoadedAccount.current = "";
|
||||
return;
|
||||
}
|
||||
if (chartLibraryLoadedAccount.current === accountId) return;
|
||||
chartLibraryLoadedAccount.current = accountId;
|
||||
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile));
|
||||
void fetchCloudChartLibrary()
|
||||
.then((cloudLibrary) => {
|
||||
setChartLibrary((current) => {
|
||||
const otherById = new Map([
|
||||
...current.filter((record) => record.role === "other").map((record) => [record.id, record] as const),
|
||||
...cloudLibrary.filter((record) => record.role === "other").map((record) => [record.id, record] as const),
|
||||
]);
|
||||
const next = upsertSelfChart([...otherById.values()], profile);
|
||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Cloud chart library is best-effort; local library remains usable.
|
||||
});
|
||||
}, [accountId, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -988,9 +1049,10 @@ export default function Home() {
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error("账户档案不存在,请重新登录后再试。");
|
||||
await saveCloudChartProfile({ ...buildSelfChartRecord(nextProfile), updatedAt: timestamp() }).catch(() => null);
|
||||
}
|
||||
|
||||
function saveOtherChart(event: FormEvent<HTMLFormElement>) {
|
||||
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() };
|
||||
if (missingProfileStep(nextProfile)) {
|
||||
@@ -998,12 +1060,17 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
if (!accountId) return;
|
||||
const record: ChartLibraryRecord = {
|
||||
let record: ChartLibraryRecord = {
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
role: "other",
|
||||
profile: nextProfile,
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
try {
|
||||
record = await saveCloudChartProfile(record);
|
||||
} catch {
|
||||
// Keep local chart library usable when cloud sync is unavailable.
|
||||
}
|
||||
setChartLibrary((current) => {
|
||||
const next = [...upsertSelfChart(current, profile), record];
|
||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||
@@ -1016,6 +1083,9 @@ export default function Home() {
|
||||
|
||||
function deleteOtherChart(recordId: string) {
|
||||
if (!accountId) return;
|
||||
void deleteCloudChartProfile(recordId).catch(() => {
|
||||
// Local deletion should not be blocked by temporary cloud sync failures.
|
||||
});
|
||||
setChartLibrary((current) => {
|
||||
const next = current.filter((record) => record.id !== recordId || record.role === "self");
|
||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
begin;
|
||||
|
||||
create table if not exists public.chart_profiles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
role text not null default 'other' check (role in ('self', 'other')),
|
||||
profile jsonb not null check (jsonb_typeof(profile) = 'object'),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists chart_profiles_user_updated_at_idx
|
||||
on public.chart_profiles (user_id, updated_at desc);
|
||||
|
||||
create unique index if not exists chart_profiles_one_self_per_user_idx
|
||||
on public.chart_profiles (user_id)
|
||||
where role = 'self';
|
||||
|
||||
alter table public.chart_profiles enable row level security;
|
||||
|
||||
drop policy if exists chart_profiles_select_own on public.chart_profiles;
|
||||
create policy chart_profiles_select_own
|
||||
on public.chart_profiles
|
||||
for select
|
||||
to authenticated
|
||||
using ((select auth.uid()) = user_id);
|
||||
|
||||
drop policy if exists chart_profiles_insert_own on public.chart_profiles;
|
||||
create policy chart_profiles_insert_own
|
||||
on public.chart_profiles
|
||||
for insert
|
||||
to authenticated
|
||||
with check ((select auth.uid()) = user_id);
|
||||
|
||||
drop policy if exists chart_profiles_update_own on public.chart_profiles;
|
||||
create policy chart_profiles_update_own
|
||||
on public.chart_profiles
|
||||
for update
|
||||
to authenticated
|
||||
using ((select auth.uid()) = user_id)
|
||||
with check ((select auth.uid()) = user_id);
|
||||
|
||||
drop policy if exists chart_profiles_delete_own on public.chart_profiles;
|
||||
create policy chart_profiles_delete_own
|
||||
on public.chart_profiles
|
||||
for delete
|
||||
to authenticated
|
||||
using ((select auth.uid()) = user_id);
|
||||
|
||||
revoke all on table public.chart_profiles from anon, authenticated, service_role;
|
||||
grant select on table public.chart_profiles to authenticated;
|
||||
grant insert (id, user_id, role, profile, updated_at) on table public.chart_profiles to authenticated;
|
||||
grant update (role, profile, updated_at) on table public.chart_profiles to authenticated;
|
||||
grant delete on table public.chart_profiles to authenticated;
|
||||
|
||||
commit;
|
||||
@@ -23,7 +23,16 @@ CONSULTATION_MIGRATION = (
|
||||
/ "migrations"
|
||||
/ "20260717000000_consultation_request_lifecycle.sql"
|
||||
)
|
||||
CHART_PROFILE_MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "frontend"
|
||||
/ "supabase"
|
||||
/ "migrations"
|
||||
/ "20260717010000_chart_profiles.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"
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
@@ -92,9 +101,54 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None:
|
||||
assert 'await persistSession(interruptedSession)' in source
|
||||
assert 'await persistence' in source
|
||||
assert 'disabled={Boolean(pendingSessionId) || cancellationPending}' in source
|
||||
assert "localStorage" not in source
|
||||
assert "ayanam-profile" not in source
|
||||
assert "ayanam-sessions" not in source
|
||||
assert 'localStorage.setItem(chartLibraryStorageKey(accountId)' in source
|
||||
assert 'localStorage.setItem("chat_sessions"' not in source
|
||||
|
||||
|
||||
def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None:
|
||||
sql = re.sub(r"\s+", " ", CHART_PROFILE_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||||
route = CHART_PROFILE_ROUTE.read_text(encoding="utf-8")
|
||||
delete_route = CHART_PROFILE_DELETE_ROUTE.read_text(encoding="utf-8")
|
||||
page = PAGE.read_text(encoding="utf-8")
|
||||
|
||||
for token in (
|
||||
"create table if not exists public.chart_profiles",
|
||||
"user_id uuid not null references auth.users(id) on delete cascade",
|
||||
"role text not null default 'other' check (role in ('self', 'other'))",
|
||||
"profile jsonb not null check (jsonb_typeof(profile) = 'object')",
|
||||
"create unique index if not exists chart_profiles_one_self_per_user_idx",
|
||||
"alter table public.chart_profiles enable row level security",
|
||||
"create policy chart_profiles_select_own",
|
||||
"create policy chart_profiles_insert_own",
|
||||
"create policy chart_profiles_update_own",
|
||||
"create policy chart_profiles_delete_own",
|
||||
"grant delete on table public.chart_profiles to authenticated",
|
||||
):
|
||||
assert token in sql
|
||||
|
||||
for token in (
|
||||
'from("chart_profiles")',
|
||||
'eq("user_id", user.id)',
|
||||
'eq("role", "self")',
|
||||
'insert({ user_id: user.id, role, profile: body.profile',
|
||||
'upsert(record, { onConflict: "id" })',
|
||||
):
|
||||
assert token in route
|
||||
assert 'eq("role", "other")' in delete_route
|
||||
|
||||
for token in (
|
||||
"fetchCloudChartLibrary",
|
||||
"saveCloudChartProfile",
|
||||
"deleteCloudChartProfile",
|
||||
"chartLibraryStorageKey",
|
||||
"Cloud chart library is best-effort",
|
||||
"星盘库",
|
||||
"添加其他星盘",
|
||||
"设为默认",
|
||||
):
|
||||
assert token in page
|
||||
assert "ayanam-profile" not in page
|
||||
assert "ayanam-sessions" not in page
|
||||
|
||||
|
||||
def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None:
|
||||
|
||||
Reference in New Issue
Block a user