fix(report): stop listing reports via PostgREST JSON paths (BUG-574)
Staging PostgREST rejects the executiveSummary JSON-path alias, so GET /api/reports 500s. Persist a plain card_summary column from the Markdown excerpt instead. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -374,6 +374,11 @@ profile time is not changed without an explicit current acceptance. Do not recor
|
|||||||
birth data, narrative, account identifiers, tokens, or model prompts in smoke
|
birth data, narrative, account identifiers, tokens, or model prompts in smoke
|
||||||
evidence.
|
evidence.
|
||||||
|
|
||||||
|
After a report-centre deployment, also run the authenticated list/detail/create
|
||||||
|
smoke in `docs/operations/personal-report-staging.md` (section “Post-deploy
|
||||||
|
report list / detail / create smoke”): `GET /api/reports` 200, owned
|
||||||
|
`GET /api/reports/<id>` 200, and one new `POST /api/reports` through ready.
|
||||||
|
|
||||||
## Common operations
|
## Common operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -8838,4 +8838,20 @@
|
|||||||
- 复发自:unknown-time `block_scan`(`814c924e`)按时段长度偏置
|
- 复发自:unknown-time `block_scan`(`814c924e`)按时段长度偏置
|
||||||
- 修复版本:`517df002`
|
- 修复版本:`517df002`
|
||||||
|
|
||||||
|
## BUG-574 | 报告列表 JSON 路径 select 在 staging PostgREST 上 500
|
||||||
|
|
||||||
|
- 状态:resolved
|
||||||
|
- 首次发现:2026-09-07
|
||||||
|
- 最近更新:2026-09-07
|
||||||
|
- 影响面:`GET /api/reports`、`personal_reports` 列表查询、报告中心卡片
|
||||||
|
- 用户现象:登录后报告中心无法打开列表,接口稳定返回 500 `{"error":"报告列表暂时无法读取","code":"report_generation_failed"}`。同一会话读取已有报告详情 200。
|
||||||
|
- 触发条件:部署含 `cfcd369d` 的应用后打开报告中心。自托管 PostgREST 解析 `card_summary:report_document->executiveSummary->>summary` 失败。
|
||||||
|
- 根因:列表 `select` 增加了 PostgREST JSON 路径别名。该语法未被 `test:db` 或真实 PostgREST 覆盖;supabase-js 返回的 PostgrestError 不是 `Error` 实例,列表日志只记 `UnknownError`。
|
||||||
|
- 修复:列表改为普通列 `card_summary`;迁移增加可空 `text` 列(≤500 字符);`complete_personal_report_job` 从封面文档 `executiveSummary.summary`(MD「摘要」截取)写入。旧行不回填。列表 catch 记录 `code`/`hint`,不记行数据。
|
||||||
|
- 验证:`frontend/tests/database-personal-report-card-summary.test.ts`(可空、authenticated 只读、service_role 可写、超长拒绝、complete RPC 写入);列表/观测单测;部署后需按 `docs/operations/personal-report-staging.md` 用真实会话跑列表 GET + 详情 GET + 创建 POST。
|
||||||
|
- 防复发:新增 PostgREST JSON 路径、嵌套 embed 或别名必须有 `test:db` 真查询或部署后 smoke;禁止再用未经验证的 JSON 路径变体赌列表查询。
|
||||||
|
- 相关记录:无
|
||||||
|
- 复发自:无
|
||||||
|
- 修复版本:待提交(`codex/report-list-500-20260907`)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ curl -sS -o /dev/null -w '%{http_code}\n' https://staging.jyotisha.chat/api/repo
|
|||||||
|
|
||||||
Expected logged-out status is `401`. Authenticated owner/non-owner checks must be performed in the browser or with ephemeral local credentials that are never pasted into logs.
|
Expected logged-out status is `401`. Authenticated owner/non-owner checks must be performed in the browser or with ephemeral local credentials that are never pasted into logs.
|
||||||
|
|
||||||
|
### Post-deploy report list / detail / create smoke (required)
|
||||||
|
|
||||||
|
Apply `20260907010000_personal_report_card_summary.sql` before deploying application code that selects `personal_reports.card_summary`. After `/api/health` shows the target SHA, use a real logged-in staging session (do not paste cookies, JWTs, or birth data):
|
||||||
|
|
||||||
|
1. `GET /api/reports` → **200** with `{ reports: [...] }`. Missing `cardSummary` on old rows is success, not an error. Must not return `report_generation_failed`.
|
||||||
|
2. `GET /api/reports/<existing-id>` → **200** for an owned report (regression: list 500 while detail 200 was the 2026-09-07 incident).
|
||||||
|
3. `POST /api/reports` create one new longform report, then poll until `status=ready` (about 10–30s). Confirm:
|
||||||
|
- detail page TOC and wide tables render from Markdown
|
||||||
|
- export downloads `.md`
|
||||||
|
- billing settles `inputTokens=0 outputTokens=0` on the catalog model
|
||||||
|
- the new list card shows `cardSummary` from the Markdown 摘要 excerpt
|
||||||
|
|
||||||
|
Do not record report bodies, birth facts, user ids, or tokens in the smoke log. Record only HTTP status, `status=ready` latency band, and whether `cardSummary` was present.
|
||||||
|
|
||||||
On the host, confirm no product PDF browser runtime exists:
|
On the host, confirm no product PDF browser runtime exists:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# PROGRESS · 报告列表 500:PostgREST JSON 路径(2026-09-07)
|
||||||
|
|
||||||
|
工作树:`.worktrees/report-list-500-20260907`
|
||||||
|
分支:`codex/report-list-500-20260907`
|
||||||
|
基线:`origin/staging` @ `ad9283d1`(任务书写 `5c644f92`,开工 fetch 后 HEAD 为准)
|
||||||
|
任务书:`TASK-report-list-500-20260907.md`
|
||||||
|
未改 `.gitea/workflows/**`,不提升 main。
|
||||||
|
|
||||||
|
## 开工回执
|
||||||
|
|
||||||
|
- 目标:`GET /api/reports` 恢复 200;卡片摘要改普通列;列表错误日志能看到 PostgREST `code`/`hint`。
|
||||||
|
- 最大风险:应用若在迁移前部署,`select card_summary` 会再次 500。必须先 `Migrate Staging Database` 再部署 web。
|
||||||
|
|
||||||
|
## 任务状态
|
||||||
|
|
||||||
|
| 任务 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 止血:去掉 JSON 路径 select | 完成 | `REPORT_LIST_COLUMNS` 只含普通列 `card_summary` |
|
||||||
|
| 2 `card_summary` 列 + worker 写入 | 完成 | 迁移 `20260907010000`;`complete_personal_report_job` 从 `executiveSummary.summary` 写入(与 MD「摘要」截取同源);旧行不回填 |
|
||||||
|
| 3 观测 | 完成 | `sanitizedErrorReason` 读取非 Error 的 `code`/`hint`,不记 message/details |
|
||||||
|
| 4 部署后 smoke | blocked | 清单已写入 `docs/operations/personal-report-staging.md`;需迁移+部署+真实登录会话。本环境无 staging 登录态 |
|
||||||
|
|
||||||
|
## 偏离
|
||||||
|
|
||||||
|
任务书写「由 worker 从 MD 摘要段写入」。实现上 worker 仍通过 `extractLongformSummary(..., 500)` 写入封面 `executiveSummary.summary`,完成 RPC 在同一事务拷到 `card_summary`。没有另开一次 PostgREST JSON 路径 select,也没有改 RPC 参数签名。
|
||||||
|
|
||||||
|
## 质量门(本机跑过)
|
||||||
|
|
||||||
|
```text
|
||||||
|
tsx --test tests/safe-error-reason.test.ts tests/personal-report-longform-md.test.ts \
|
||||||
|
tests/personal-report-migration.test.ts tests/personal-report-api.test.ts
|
||||||
|
# 77 passed
|
||||||
|
|
||||||
|
tsx --test --test-concurrency=1 tests/database-personal-report-card-summary.test.ts
|
||||||
|
# 1 passed(Docker Postgres 真迁移:可空、authenticated 只读、service_role 可写、500 接受、501 拒绝、complete RPC 写入摘要)
|
||||||
|
|
||||||
|
./node_modules/.bin/tsc --noEmit
|
||||||
|
# pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推送
|
||||||
|
|
||||||
|
未推。产品要求只快进 staging、不提升 main。
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
| `TASK-report-longform-parity-20260905.md`(仓库根) | `PROGRESS-report-longform-parity-20260905.md` | 长报告对齐全量版并挂入产品附录 | 待验收 | `codex/report-longform-parity-20260905`(任务 6 待部署后核对) |
|
| `TASK-report-longform-parity-20260905.md`(仓库根) | `PROGRESS-report-longform-parity-20260905.md` | 长报告对齐全量版并挂入产品附录 | 待验收 | `codex/report-longform-parity-20260905`(任务 6 待部署后核对) |
|
||||||
| `TASK-report-longform-gaps2-20260906.md`(仓库根) | `PROGRESS-report-longform-gaps2-20260906.md` | 长报告真实参数组合下的装配缺口 | 待验收 | `codex/report-longform-gaps2-20260906`(BUG-561/562 `cfcd369d`;补洞 BUG-564 `e4d16b75`) |
|
| `TASK-report-longform-gaps2-20260906.md`(仓库根) | `PROGRESS-report-longform-gaps2-20260906.md` | 长报告真实参数组合下的装配缺口 | 待验收 | `codex/report-longform-gaps2-20260906`(BUG-561/562 `cfcd369d`;补洞 BUG-564 `e4d16b75`) |
|
||||||
| `TASK-report-md-page-20260906.md` | `PROGRESS-report-md-page-20260906.md` | 长报告 Markdown 直接作为报告页 | 已合入 | `cfcd369d` / `809bdf13`(BUG-563 lint 随后修) |
|
| `TASK-report-md-page-20260906.md` | `PROGRESS-report-md-page-20260906.md` | 长报告 Markdown 直接作为报告页 | 已合入 | `cfcd369d` / `809bdf13`(BUG-563 lint 随后修) |
|
||||||
|
| `TASK-report-list-500-20260907.md`(仓库根) | `PROGRESS-report-list-500-20260907.md` | 列表 PostgREST JSON 路径 500 | 执行中 | `codex/report-list-500-20260907`(BUG-574) |
|
||||||
|
|
||||||
### 前端基础与工程
|
### 前端基础与工程
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { loadReportCandidateRange } from "@/lib/report-candidate-range";
|
|||||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||||
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
|
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
|
||||||
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
|
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||||
|
import { sanitizedErrorReason } from "@/lib/safe-error-reason";
|
||||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ const REPORT_LIST_COLUMNS = [
|
|||||||
"failure_code",
|
"failure_code",
|
||||||
"created_at",
|
"created_at",
|
||||||
"completed_at",
|
"completed_at",
|
||||||
"card_summary:report_document->executiveSummary->>summary",
|
"card_summary",
|
||||||
].join(",");
|
].join(",");
|
||||||
|
|
||||||
function sanitizedErrorCode(error: unknown): string {
|
function sanitizedErrorCode(error: unknown): string {
|
||||||
@@ -139,7 +140,7 @@ export async function GET() {
|
|||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.error(`[reports] list failed reason=${sanitizedErrorCode(error)}`);
|
console.error(`[reports] list failed reason=${sanitizedErrorReason(error)}`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed },
|
{ error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
REPORT_DOCUMENT_V2_SCHEMA_VERSION,
|
REPORT_DOCUMENT_V2_SCHEMA_VERSION,
|
||||||
type ReportDocumentV2,
|
type ReportDocumentV2,
|
||||||
} from "./personal-report-contract.ts";
|
} from "./personal-report-contract.ts";
|
||||||
import { extractLongformSummary } from "./personal-report-longform-outline.ts";
|
import {
|
||||||
|
PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS,
|
||||||
|
extractLongformSummary,
|
||||||
|
} from "./personal-report-longform-outline.ts";
|
||||||
import type { PersonalReportRecord } from "./personal-report-service-core.ts";
|
import type { PersonalReportRecord } from "./personal-report-service-core.ts";
|
||||||
|
|
||||||
type CoverSkillSnapshot = Readonly<{
|
type CoverSkillSnapshot = Readonly<{
|
||||||
@@ -52,7 +55,7 @@ function coverHouses(): ReportDocumentV2["charts"][number]["houses"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildLongformCoverDocument(input: LongformCoverInput): ReportDocumentV2 {
|
export function buildLongformCoverDocument(input: LongformCoverInput): ReportDocumentV2 {
|
||||||
const summary = extractLongformSummary(input.markdown);
|
const summary = extractLongformSummary(input.markdown, PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS);
|
||||||
const skillName = input.skillSnapshot?.name ?? input.report.skillName;
|
const skillName = input.skillSnapshot?.name ?? input.report.skillName;
|
||||||
const skillVersion = input.skillSnapshot?.version ?? input.report.skillVersion;
|
const skillVersion = input.skillSnapshot?.version ?? input.report.skillVersion;
|
||||||
const skillSnapshotSha256 = input.skillSnapshot?.sha256 ?? input.report.skillSnapshotSha256;
|
const skillSnapshotSha256 = input.skillSnapshot?.sha256 ?? input.report.skillSnapshotSha256;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export type LongformOutline = Readonly<{
|
|||||||
const EAGER_H2 = /成品阅读导航|质量验收矩阵/;
|
const EAGER_H2 = /成品阅读导航|质量验收矩阵/;
|
||||||
const SUMMARY_TITLE = /^(?:解读摘要|摘要)$/;
|
const SUMMARY_TITLE = /^(?:解读摘要|摘要)$/;
|
||||||
|
|
||||||
|
/** Dedicated `personal_reports.card_summary` column length. */
|
||||||
|
export const PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS = 500;
|
||||||
|
|
||||||
export function slugifyHeading(title: string, used: Map<string, number>): string {
|
export function slugifyHeading(title: string, used: Map<string, number>): string {
|
||||||
const base = title
|
const base = title
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Operational log reason for unknown catch values.
|
||||||
|
*
|
||||||
|
* PostgREST / supabase-js may throw a plain object with `code` and `hint`
|
||||||
|
* instead of an Error instance. Never include message, details, or row data.
|
||||||
|
*/
|
||||||
|
export function sanitizedErrorReason(error: unknown): string {
|
||||||
|
const record = error !== null && typeof error === "object"
|
||||||
|
? error as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
const name = error instanceof Error && error.name.trim()
|
||||||
|
? error.name.trim()
|
||||||
|
: typeof record?.name === "string" && record.name.trim()
|
||||||
|
? record.name.trim()
|
||||||
|
: "UnknownError";
|
||||||
|
const code = typeof record?.code === "string" ? record.code.trim() : "";
|
||||||
|
const hint = typeof record?.hint === "string" ? record.hint.trim() : "";
|
||||||
|
if (!code && !hint) return name;
|
||||||
|
return `${name} code=${code || "none"} hint=${hint || "none"}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
-- Dedicated list-card excerpt. PostgREST JSON-path aliases on report_document
|
||||||
|
-- are rejected by the self-hosted PostgREST used in staging; this plain text
|
||||||
|
-- column is the only list-select form. Worker completion copies the Markdown
|
||||||
|
-- 摘要 excerpt already stored on executiveSummary.summary. Old rows stay null.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
do $migration$
|
||||||
|
begin
|
||||||
|
if current_user <> 'schema_owner' then
|
||||||
|
raise exception 'personal_report_card_summary_requires_schema_owner'
|
||||||
|
using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$migration$;
|
||||||
|
|
||||||
|
alter table public.personal_reports
|
||||||
|
add column if not exists card_summary text;
|
||||||
|
|
||||||
|
alter table public.personal_reports
|
||||||
|
drop constraint if exists personal_reports_card_summary_length_check;
|
||||||
|
|
||||||
|
alter table public.personal_reports
|
||||||
|
add constraint personal_reports_card_summary_length_check
|
||||||
|
check (card_summary is null or char_length(card_summary) <= 500);
|
||||||
|
|
||||||
|
comment on column public.personal_reports.card_summary is
|
||||||
|
'Optional list-card excerpt, at most 500 characters. Owner SELECT only; writes are service_role via complete_personal_report_job. Not backfilled.';
|
||||||
|
|
||||||
|
-- Same signature as 20260814040000. Adds card_summary from the cover excerpt.
|
||||||
|
create or replace function public.complete_personal_report_job(
|
||||||
|
p_job_id uuid,
|
||||||
|
p_lease_token uuid,
|
||||||
|
p_user_id uuid,
|
||||||
|
p_report_id uuid,
|
||||||
|
p_request_id uuid,
|
||||||
|
p_request_fingerprint text,
|
||||||
|
p_schema_version text,
|
||||||
|
p_report_document jsonb,
|
||||||
|
p_calculation_hash text,
|
||||||
|
p_evidence_hash text,
|
||||||
|
p_skill_name text,
|
||||||
|
p_skill_version text,
|
||||||
|
p_skill_source_commit text,
|
||||||
|
p_skill_snapshot_sha256 text
|
||||||
|
)
|
||||||
|
returns setof public.personal_report_jobs
|
||||||
|
language plpgsql
|
||||||
|
set search_path = pg_catalog, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_job public.personal_report_jobs%rowtype;
|
||||||
|
v_report public.personal_reports%rowtype;
|
||||||
|
v_completed_at timestamptz;
|
||||||
|
begin
|
||||||
|
if p_schema_version <> 'report_document.v2'
|
||||||
|
or p_request_fingerprint !~ '^[0-9a-f]{64}$'
|
||||||
|
or p_calculation_hash !~ '^[0-9a-f]{64}$'
|
||||||
|
or p_evidence_hash !~ '^[0-9a-f]{64}$'
|
||||||
|
or p_skill_name is null
|
||||||
|
or p_skill_name !~ '^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$'
|
||||||
|
or p_skill_version is null
|
||||||
|
or length(p_skill_version) not between 5 and 80
|
||||||
|
or p_skill_version !~ '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
|
||||||
|
or p_skill_snapshot_sha256 !~ '^[0-9a-f]{64}$'
|
||||||
|
or (p_skill_source_commit is not null and p_skill_source_commit !~ '^[0-9a-f]{40}$') then
|
||||||
|
raise exception using errcode = '22023', message = 'personal_report_completion_payload_invalid';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select job.*
|
||||||
|
into v_job
|
||||||
|
from public.personal_report_jobs as job
|
||||||
|
where job.id = p_job_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception using errcode = 'P0002', message = 'personal_report_job_not_found';
|
||||||
|
end if;
|
||||||
|
v_completed_at := clock_timestamp();
|
||||||
|
if v_job.user_id is distinct from p_user_id
|
||||||
|
or v_job.request_id is distinct from p_request_id
|
||||||
|
or v_job.request_fingerprint is distinct from p_request_fingerprint then
|
||||||
|
raise exception using errcode = '22023', message = 'personal_report_completion_identity_mismatch';
|
||||||
|
end if;
|
||||||
|
if v_job.status <> 'running'
|
||||||
|
or v_job.lease_token is distinct from p_lease_token
|
||||||
|
or v_job.lease_expires_at <= v_completed_at then
|
||||||
|
raise exception using errcode = '55000', message = 'personal_report_job_lease_lost';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select report.*
|
||||||
|
into v_report
|
||||||
|
from public.personal_reports as report
|
||||||
|
where report.id = p_report_id
|
||||||
|
and report.user_id = p_user_id
|
||||||
|
and report.request_id = p_request_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception using errcode = 'P0002', message = 'personal_report_not_found';
|
||||||
|
end if;
|
||||||
|
if v_report.status <> 'generating' then
|
||||||
|
raise exception using errcode = '55000', message = 'personal_report_completion_invalid_state';
|
||||||
|
end if;
|
||||||
|
if v_report.request_fingerprint is distinct from p_request_fingerprint
|
||||||
|
or v_report.skill_name is distinct from p_skill_name
|
||||||
|
or v_report.skill_version is distinct from p_skill_version
|
||||||
|
or v_report.skill_source_commit is distinct from p_skill_source_commit
|
||||||
|
or v_report.skill_snapshot_sha256 is distinct from p_skill_snapshot_sha256 then
|
||||||
|
raise exception using errcode = '22023', message = 'personal_report_completion_identity_mismatch';
|
||||||
|
end if;
|
||||||
|
if p_report_document is null
|
||||||
|
or p_report_document ->> 'schemaVersion' <> p_schema_version
|
||||||
|
or p_report_document ->> 'reportId' <> p_report_id::text
|
||||||
|
or p_report_document #>> '{provenance,calculationHash}' <> p_calculation_hash
|
||||||
|
or p_report_document #>> '{provenance,evidenceHash}' <> p_evidence_hash
|
||||||
|
or p_report_document #>> '{provenance,skillName}' <> p_skill_name
|
||||||
|
or p_report_document #>> '{provenance,skillVersion}' <> p_skill_version
|
||||||
|
or p_report_document #>> '{provenance,skillSnapshotSha256}' <> p_skill_snapshot_sha256
|
||||||
|
or (p_report_document #>> '{provenance,skillSourceCommit}') is distinct from p_skill_source_commit then
|
||||||
|
raise exception using errcode = '22023', message = 'personal_report_completion_document_mismatch';
|
||||||
|
end if;
|
||||||
|
if v_report.calculation_hash is not null
|
||||||
|
and v_report.calculation_hash is distinct from p_calculation_hash then
|
||||||
|
raise exception using errcode = '22023', message = 'personal_report_completion_hash_mismatch';
|
||||||
|
end if;
|
||||||
|
if v_report.evidence_hash is not null
|
||||||
|
and v_report.evidence_hash is distinct from p_evidence_hash then
|
||||||
|
raise exception using errcode = '22023', message = 'personal_report_completion_hash_mismatch';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.personal_reports as report
|
||||||
|
set status = 'ready',
|
||||||
|
schema_version = p_schema_version,
|
||||||
|
report_document = p_report_document,
|
||||||
|
calculation_hash = p_calculation_hash,
|
||||||
|
evidence_hash = p_evidence_hash,
|
||||||
|
failure_code = null,
|
||||||
|
completed_at = v_completed_at,
|
||||||
|
updated_at = v_completed_at,
|
||||||
|
card_summary = nullif(
|
||||||
|
left(btrim(coalesce(p_report_document #>> '{executiveSummary,summary}', '')), 500),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
where report.id = p_report_id;
|
||||||
|
|
||||||
|
return query
|
||||||
|
update public.personal_report_jobs as job
|
||||||
|
set status = 'ready',
|
||||||
|
lease_token = null,
|
||||||
|
lease_owner = null,
|
||||||
|
lease_acquired_at = null,
|
||||||
|
lease_expires_at = null,
|
||||||
|
heartbeat_at = v_completed_at,
|
||||||
|
next_attempt_at = null,
|
||||||
|
progress_phase = 'ready',
|
||||||
|
progress_percent = 100,
|
||||||
|
last_error_code = null,
|
||||||
|
last_error_at = null,
|
||||||
|
finished_at = v_completed_at
|
||||||
|
where job.id = p_job_id
|
||||||
|
returning job.*;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||||
|
|
||||||
|
const runnerPath = fileURLToPath(
|
||||||
|
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
function dockerAvailable(): boolean {
|
||||||
|
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: "ignore",
|
||||||
|
}).status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
|
||||||
|
|
||||||
|
const USER_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const USER_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
const REPORT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||||
|
const REQUEST_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||||
|
const HASH = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||||
|
const COMMIT = "2222222222222222222222222222222222222222";
|
||||||
|
const SKILL_NAME = "jyotish-personal-report";
|
||||||
|
const SKILL_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
function sqlLiteral(value: string): string {
|
||||||
|
return `'${value.replaceAll("'", "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAsAuthenticated(userId: string, sql: string): string {
|
||||||
|
return `
|
||||||
|
set role authenticated;
|
||||||
|
select set_config('request.jwt.claim.sub', '${userId}', true);
|
||||||
|
${sql}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceSql(sql: string): string {
|
||||||
|
return `set role service_role;\n${sql}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFor(reportId: string, summary: string): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
schemaVersion: "report_document.v2",
|
||||||
|
reportId,
|
||||||
|
provenance: {
|
||||||
|
calculationHash: HASH,
|
||||||
|
evidenceHash: HASH,
|
||||||
|
skillName: SKILL_NAME,
|
||||||
|
skillVersion: SKILL_VERSION,
|
||||||
|
skillSourceCommit: COMMIT,
|
||||||
|
skillSnapshotSha256: HASH,
|
||||||
|
},
|
||||||
|
executiveSummary: { summary },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("personal_reports.card_summary is nullable, owner-read, service-written, and length-capped", { skip: skipWithoutDocker }, () => {
|
||||||
|
const fixture = startPostgresFixture();
|
||||||
|
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||||
|
});
|
||||||
|
assert.equal(migration.status, 0, migration.stderr);
|
||||||
|
assert.match(migration.stdout, /applied 20260907010000_personal_report_card_summary\.sql/);
|
||||||
|
|
||||||
|
fixture.psql(`
|
||||||
|
insert into identity.users (id, name, email, email_verified, email_verified_at)
|
||||||
|
values
|
||||||
|
('${USER_A}', 'Card Summary User A', 'card-a@example.com', true, now()),
|
||||||
|
('${USER_B}', 'Card Summary User B', 'card-b@example.com', true, now());
|
||||||
|
insert into public.personal_reports (
|
||||||
|
id, user_id, request_id, request_fingerprint, report_type, status,
|
||||||
|
schema_version, presentation_mode, requested_themes, depth,
|
||||||
|
skill_name, skill_version, skill_source_commit, skill_snapshot_sha256
|
||||||
|
) values (
|
||||||
|
'${REPORT_ID}', '${USER_A}', '${REQUEST_ID}', '${HASH}', 'personal_full', 'generating',
|
||||||
|
'report_document.v2', 'default', array['career']::text[], 'standard',
|
||||||
|
'${SKILL_NAME}', '${SKILL_VERSION}', '${COMMIT}', '${HASH}'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select (card_summary is null)::text from public.personal_reports where id = '${REPORT_ID}'
|
||||||
|
`),
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => fixture.psqlAs(
|
||||||
|
"app_runtime",
|
||||||
|
"app-runtime-test-password",
|
||||||
|
selectAsAuthenticated(USER_A, `
|
||||||
|
update public.personal_reports
|
||||||
|
set card_summary = 'owner write'
|
||||||
|
where id = '${REPORT_ID}'
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
/permission denied for table personal_reports/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobId = fixture.psql(`
|
||||||
|
select id from public.personal_report_jobs
|
||||||
|
where user_id = '${USER_A}' and request_id = '${REQUEST_ID}'
|
||||||
|
`);
|
||||||
|
assert.match(jobId, /^[0-9a-f-]{36}$/);
|
||||||
|
const claim = fixture.psqlAs(
|
||||||
|
"service_runtime",
|
||||||
|
"service-runtime-test-password",
|
||||||
|
`set role service_role;
|
||||||
|
select lease_token::text
|
||||||
|
from public.claim_personal_report_job('card-summary-worker', 60, '${jobId}');`,
|
||||||
|
);
|
||||||
|
const leaseToken = claim.split("\n").at(-1)!;
|
||||||
|
assert.match(leaseToken, /^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
fixture.psqlAs(
|
||||||
|
"service_runtime",
|
||||||
|
"service-runtime-test-password",
|
||||||
|
serviceSql(`
|
||||||
|
select status
|
||||||
|
from public.complete_personal_report_job(
|
||||||
|
'${jobId}', '${leaseToken}', '${USER_A}', '${REPORT_ID}',
|
||||||
|
'${REQUEST_ID}', '${HASH}', 'report_document.v2',
|
||||||
|
${sqlLiteral(documentFor(REPORT_ID, "事业方向保持观察"))}::jsonb,
|
||||||
|
'${HASH}', '${HASH}', '${SKILL_NAME}',
|
||||||
|
'${SKILL_VERSION}', '${COMMIT}', '${HASH}'
|
||||||
|
);
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
"SET\nready",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`select card_summary from public.personal_reports where id = '${REPORT_ID}'`),
|
||||||
|
"事业方向保持观察",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
fixture.psqlAs(
|
||||||
|
"app_runtime",
|
||||||
|
"app-runtime-test-password",
|
||||||
|
selectAsAuthenticated(USER_A, `
|
||||||
|
select card_summary from public.personal_reports where id = '${REPORT_ID}'
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
`SET\n${USER_A}\n事业方向保持观察`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psqlAs(
|
||||||
|
"app_runtime",
|
||||||
|
"app-runtime-test-password",
|
||||||
|
selectAsAuthenticated(USER_B, `
|
||||||
|
select count(*) from public.personal_reports where id = '${REPORT_ID}'
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
`SET\n${USER_B}\n0`,
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.psqlAs(
|
||||||
|
"service_runtime",
|
||||||
|
"service-runtime-test-password",
|
||||||
|
serviceSql(`
|
||||||
|
update public.personal_reports
|
||||||
|
set card_summary = '${"测".repeat(500)}'
|
||||||
|
where id = '${REPORT_ID}'
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select char_length(card_summary) from public.personal_reports where id = '${REPORT_ID}'
|
||||||
|
`),
|
||||||
|
"500",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => fixture.psqlAs(
|
||||||
|
"service_runtime",
|
||||||
|
"service-runtime-test-password",
|
||||||
|
serviceSql(`
|
||||||
|
update public.personal_reports
|
||||||
|
set card_summary = '${"测".repeat(501)}'
|
||||||
|
where id = '${REPORT_ID}'
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
/personal_reports_card_summary_length_check/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1311,11 +1311,9 @@ test("POST enqueues durable work without Next.js after and GET lists metadata wi
|
|||||||
createRoute.indexOf("function sanitizedErrorCode"),
|
createRoute.indexOf("function sanitizedErrorCode"),
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(listColumns, /calculation_hash|evidence_hash/);
|
assert.doesNotMatch(listColumns, /calculation_hash|evidence_hash/);
|
||||||
assert.match(listColumns, /card_summary:report_document->executiveSummary->>summary/);
|
assert.match(listColumns, /"card_summary"/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(listColumns, /->|->>|report_document/);
|
||||||
listColumns.replace("card_summary:report_document->executiveSummary->>summary", ""),
|
assert.match(createRoute, /sanitizedErrorReason/);
|
||||||
/report_document/,
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/);
|
assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/);
|
||||||
assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/);
|
assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/);
|
||||||
assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/);
|
assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
|
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
|
||||||
} from "../src/lib/personal-report-longform-copy.ts";
|
} from "../src/lib/personal-report-longform-copy.ts";
|
||||||
import {
|
import {
|
||||||
|
PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS,
|
||||||
buildLongformOutline,
|
buildLongformOutline,
|
||||||
extractLongformSummary,
|
extractLongformSummary,
|
||||||
personalReportMarkdownFilename,
|
personalReportMarkdownFilename,
|
||||||
@@ -62,12 +63,14 @@ const UNSAFE_MARKDOWN = [
|
|||||||
|
|
||||||
test("report centre cards read the stored Markdown excerpt, not a writer summary field name", () => {
|
test("report centre cards read the stored Markdown excerpt, not a writer summary field name", () => {
|
||||||
const listRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
const listRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||||
assert.match(listRoute, /card_summary:report_document->executiveSummary->>summary/);
|
assert.match(listRoute, /"card_summary"/);
|
||||||
|
assert.doesNotMatch(listRoute, /report_document->|->>summary/);
|
||||||
const coverSource = readFileSync(
|
const coverSource = readFileSync(
|
||||||
new URL("../src/lib/personal-report-longform-cover.ts", import.meta.url),
|
new URL("../src/lib/personal-report-longform-cover.ts", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(coverSource, /extractLongformSummary/);
|
assert.match(coverSource, /extractLongformSummary/);
|
||||||
|
assert.match(coverSource, /PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writer pipeline stays in the tree but is feature-off", () => {
|
test("writer pipeline stays in the tree but is feature-off", () => {
|
||||||
@@ -89,6 +92,7 @@ test("outline lifts navigation and summary to the first screen", () => {
|
|||||||
|
|
||||||
test("card excerpt comes from the Markdown 摘要 section", () => {
|
test("card excerpt comes from the Markdown 摘要 section", () => {
|
||||||
const excerpt = extractLongformSummary(SAMPLE_MARKDOWN, 80);
|
const excerpt = extractLongformSummary(SAMPLE_MARKDOWN, 80);
|
||||||
|
assert.equal(PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS, 500);
|
||||||
assert.match(excerpt, /事业方向保持观察/);
|
assert.match(excerpt, /事业方向保持观察/);
|
||||||
assert.doesNotMatch(excerpt, /executiveSummary/);
|
assert.doesNotMatch(excerpt, /executiveSummary/);
|
||||||
assert.equal(personalReportMarkdownFilename("2026-09-06T08:00:00.000Z"), "个人报告-2026-09-06");
|
assert.equal(personalReportMarkdownFilename("2026-09-06T08:00:00.000Z"), "个人报告-2026-09-06");
|
||||||
|
|||||||
@@ -228,3 +228,16 @@ test("least privilege: anon/public revoked and no direct admin_runtime body acce
|
|||||||
assert.doesNotMatch(localMigration, /to admin_runtime/);
|
assert.doesNotMatch(localMigration, /to admin_runtime/);
|
||||||
assert.doesNotMatch(supabaseMigration, /to admin_runtime/);
|
assert.doesNotMatch(supabaseMigration, /to admin_runtime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("card_summary is a plain nullable column written by complete_personal_report_job", () => {
|
||||||
|
const sql = readFileSync(
|
||||||
|
new URL("../supabase/migrations/20260907010000_personal_report_card_summary.sql", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(sql, /add column if not exists card_summary text/);
|
||||||
|
assert.match(sql, /char_length\(card_summary\) <= 500/);
|
||||||
|
assert.match(sql, /card_summary = nullif\(/);
|
||||||
|
assert.match(sql, /p_report_document #>> '\{executiveSummary,summary\}'/);
|
||||||
|
assert.doesNotMatch(sql, /update public\.personal_reports[\s\S]*set card_summary = /);
|
||||||
|
assert.match(sql, /complete_personal_report_job\(/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { sanitizedErrorReason } from "../src/lib/safe-error-reason.ts";
|
||||||
|
|
||||||
|
test("sanitizedErrorReason reads PostgREST code/hint from non-Error objects", () => {
|
||||||
|
assert.equal(sanitizedErrorReason("boom"), "UnknownError");
|
||||||
|
assert.equal(sanitizedErrorReason(new Error("secret row")), "Error");
|
||||||
|
assert.equal(
|
||||||
|
sanitizedErrorReason({ code: "42703", hint: "column does not exist" }),
|
||||||
|
"UnknownError code=42703 hint=column does not exist",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sanitizedErrorReason({ name: "PostgrestError", code: "PGRST204", hint: "" }),
|
||||||
|
"PostgrestError code=PGRST204 hint=none",
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
sanitizedErrorReason({ code: "42703", message: "row body must not be logged", details: "secret" }),
|
||||||
|
/row body|secret/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user