Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .claude/rules/data-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
paths:
- server/src/**
---

# データアクセス

## SQL を書くのは repository だけ

`createDb` / drizzle のクエリビルダ / `sql` テンプレートを使うのは `server/src/repository/**` に限る。service・routes・handlers・mastra からは repository のメソッドを呼ぶ。

呼び出しの階層は自由(repository → service → routes / repository → routes / service → routes のいずれも可)。縛るのは「DB 操作の入口は repository」だけ。

## 置き場の判断

| 状況 | 置き場 |
| ---- | ------ |
| 返す行が 1 つのテーブルのもの(JOIN の有無は問わない) | そのテーブルの repository |
| 返り値が複数テーブルにまたがる / 主テーブルがない | service が各 repository を呼んで組み立てる |

複数テーブルを横断する処理(保管期間削除・ユーザー削除など)は service に置く。ただし service が `createDb` して直接 delete するのではなく、各 repository の削除メソッドを呼ぶオーケストレーターにする。

## 例外

パフォーマンス上どうしても 1 本の SQL で書きたい横断クエリだけ例外。その場合は「どのドメインの問いに答えるクエリか」で repository を選び、意図はメソッド名で表す(`findThreadsWithUsage` のように)。

## 既存コード

`services/analytics/*` `services/data-retention.ts` `services/persona-extractor.ts` 等にこの原則より前の直接アクセスが残っている。触る機会があれば repository へ寄せるが、この原則のためだけの一括リファクタはしない。
6 changes: 6 additions & 0 deletions server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ throw new HTTPException(404, { message: "Not found" });
- temperature を効かせたい決定的な処理は `deterministicModelConfig` を使う(reasoning を有効にすると temperature が無効化される)
- 動的 instructions: 現在日時が必要な場合のみ関数化(`lib/date.ts` の `getCurrentDateInfo()` を使用)

### データアクセス

SQL(`createDb` / drizzle / `sql` テンプレート)を書くのは `repository/` だけ。service・routes・handlers・mastra は repository のメソッドを呼ぶ。呼び出しの階層(repository → service → routes / repository → routes / service → routes)は自由。

置き場は「返す行が 1 つのテーブルなら(JOIN 有無を問わず)そのテーブルの repository、複数テーブルにまたがる / 主テーブルが無いなら service が各 repository を呼んで組み立てる」。詳細は `.claude/rules/data-access.md`。

### ルート規約

- エラー: `throw new HTTPException(code, { message })` でスロー(グローバルエラーハンドラーが `{ error: { code, message } }` 形式に変換)
Expand Down
25 changes: 24 additions & 1 deletion server/src/repository/review-repository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { asc, desc, eq, sql } from "drizzle-orm";
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
import {
createDb,
messageFeedback,
type NewRetrievalRun,
type NewReviewDecision,
retrievalRuns,
reviewDecisions,
Expand Down Expand Up @@ -95,6 +96,28 @@ export const reviewRepository = {
};
},

async insertRun(d1: D1Database, values: NewRetrievalRun) {
const db = createDb(d1);
await db.insert(retrievalRuns).values(values);
},

async linkRunsToMessage(
d1: D1Database,
answerRunId: string,
messageId: string,
) {
const db = createDb(d1);
await db
.update(retrievalRuns)
.set({ messageId })
.where(
and(
eq(retrievalRuns.answerRunId, answerRunId),
isNull(retrievalRuns.messageId),
),
);
},

async listRunsByAnswerRunId(d1: D1Database, answerRunId: string) {
const db = createDb(d1);
return await db
Expand Down
93 changes: 85 additions & 8 deletions server/src/routes/admin/corrections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,15 @@ describe("POST /", () => {
expect(res.status).toBe(404);
});

it("訂正を published で保存し発行する", async () => {
it("draft で保存し、発行成功後に published へ更新する", async () => {
vi.mocked(knowledgeSourceRepository.findByPath).mockResolvedValue(
sourceRow,
);
vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue(
vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue({
...correction,
status: "draft",
});
vi.mocked(knowledgeCorrectionRepository.update).mockResolvedValue(
correction,
);

Expand All @@ -192,25 +196,31 @@ describe("POST /", () => {
expect.objectContaining({
correctsSourcePath: "bus/index.md",
body: "土曜は運休です",
status: "published",
status: "draft",
approvedBy: "user-1",
answerRunId: "ar-1",
}),
);
expect(publishCorrection).toHaveBeenCalledWith(
expect.objectContaining({ d1: mockEnv.DB }),
correction,
expect.objectContaining({ status: "draft" }),
{ canonicalUrl: "https://example.com/bus" },
);
expect(knowledgeCorrectionRepository.update).toHaveBeenCalledWith(
mockEnv.DB,
"cor-1",
{ status: "published" },
);
});

it("発行に失敗したら 500 を返す", async () => {
vi.mocked(knowledgeSourceRepository.findByPath).mockResolvedValue(
sourceRow,
);
vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue(
correction,
);
vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue({
...correction,
status: "draft",
});
vi.mocked(publishCorrection).mockResolvedValue({
indexed: true,
status: "approved",
Expand All @@ -227,6 +237,7 @@ describe("POST /", () => {
mockEnv,
);
expect(res.status).toBe(500);
expect(knowledgeCorrectionRepository.update).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -287,6 +298,68 @@ describe("POST /{id}/retire", () => {
});
});

describe("POST /{id}/publish", () => {
it("retired の訂正は 404", async () => {
vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({
...correction,
status: "retired",
});

const res = await app.request(
authedRequest("/cor-1/publish", { method: "POST" }),
undefined,
mockEnv,
);
expect(res.status).toBe(404);
});

it("draft を再発行して published にする", async () => {
vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({
...correction,
status: "draft",
});
vi.mocked(knowledgeCorrectionRepository.update).mockResolvedValue(
correction,
);

const res = await app.request(
authedRequest("/cor-1/publish", { method: "POST" }),
undefined,
mockEnv,
);

expect(res.status).toBe(200);
expect(publishCorrection).toHaveBeenCalled();
expect(knowledgeCorrectionRepository.update).toHaveBeenCalledWith(
mockEnv.DB,
"cor-1",
{ status: "published" },
);
});

it("発行に失敗したら published にしない", async () => {
vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({
...correction,
status: "draft",
});
vi.mocked(publishCorrection).mockResolvedValue({
indexed: true,
status: "approved",
chunks: 0,
error: "embed failed",
});

const res = await app.request(
authedRequest("/cor-1/publish", { method: "POST" }),
undefined,
mockEnv,
);

expect(res.status).toBe(500);
expect(knowledgeCorrectionRepository.update).not.toHaveBeenCalled();
});
});

describe("POST /{id}/reverify", () => {
it("retired の訂正は 404", async () => {
vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({
Expand Down Expand Up @@ -321,6 +394,11 @@ describe("POST /{id}/reverify", () => {
);

expect(res.status).toBe(200);
expect(publishCorrection).toHaveBeenCalledWith(
expect.objectContaining({ d1: mockEnv.DB }),
expect.objectContaining({ approvedBy: "user-1" }),
expect.anything(),
);
expect(knowledgeCorrectionRepository.update).toHaveBeenCalledWith(
mockEnv.DB,
"cor-1",
Expand All @@ -330,7 +408,6 @@ describe("POST /{id}/reverify", () => {
verifiedAt: expect.any(String),
}),
);
expect(publishCorrection).toHaveBeenCalled();
});
});

Expand Down
80 changes: 71 additions & 9 deletions server/src/routes/admin/corrections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const CorrectionSchema = z.object({
id: z.string(),
correctsSourcePath: z.string(),
body: z.string(),
status: z.enum(["published", "retired"]),
status: z.enum(["draft", "published", "retired"]),
verifiedAt: z.string(),
approvedBy: z.string(),
relatedFeedbackId: z.string().nullable(),
Expand All @@ -42,7 +42,7 @@ const toCorrectionResponse = (correction: KnowledgeCorrection) => ({
id: correction.id,
correctsSourcePath: correction.correctsSourcePath,
body: correction.body,
status: correction.status as "published" | "retired",
status: correction.status as "draft" | "published" | "retired",
verifiedAt: correction.verifiedAt,
approvedBy: correction.approvedBy,
relatedFeedbackId: correction.relatedFeedbackId,
Expand Down Expand Up @@ -165,26 +165,32 @@ correctionsAdminRoutes.openapi(createRoute_, async (c) => {
}

const now = new Date();
const correction = await knowledgeCorrectionRepository.insert(c.env.DB, {
const draft = await knowledgeCorrectionRepository.insert(c.env.DB, {
id: crypto.randomUUID(),
correctsSourcePath,
body,
status: "published",
status: "draft",
verifiedAt: now.toISOString().slice(0, 10),
approvedBy: adminUser.id,
relatedFeedbackId,
answerRunId,
createdAt: now.toISOString(),
});

await publishOrFail(c.env, correction, {
await publishOrFail(c.env, draft, {
canonicalUrl: source.canonicalUrl ?? undefined,
});

const published = await knowledgeCorrectionRepository.update(
c.env.DB,
draft.id,
{ status: "published" },
);

return c.json(
{
message: "訂正を発行しました",
correction: toCorrectionResponse(correction),
correction: toCorrectionResponse(published),
},
200,
);
Expand Down Expand Up @@ -286,14 +292,19 @@ correctionsAdminRoutes.openapi(reverifyRoute, async (c) => {
});
}

const verifiedAt = new Date().toISOString().slice(0, 10);
await publishOrFail(c.env, {
...correction,
verifiedAt,
approvedBy: adminUser.id,
});

const updated = await knowledgeCorrectionRepository.update(c.env.DB, id, {
verifiedAt: new Date().toISOString().slice(0, 10),
verifiedAt,
approvedBy: adminUser.id,
needsReviewAt: null,
});

await publishOrFail(c.env, updated);

return c.json(
{
message: "訂正を再確認済みにしました",
Expand All @@ -303,6 +314,57 @@ correctionsAdminRoutes.openapi(reverifyRoute, async (c) => {
);
});

const publishRoute = createRoute({
method: "post",
path: "/{id}/publish",
summary: "未反映の訂正を再発行",
description: "発行に失敗して未反映のままの訂正を、R2 と検索へ再反映します",
tags: ["Admin - Corrections"],
request: {
params: z.object({ id: z.string().min(1) }),
},
responses: {
200: {
description: "再発行成功",
content: {
"application/json": {
schema: z.object({
message: z.string(),
correction: CorrectionSchema,
}),
},
},
},
401: errorResponse(401),
403: errorResponse(403),
404: errorResponse(404),
500: errorResponse(500),
},
});

correctionsAdminRoutes.openapi(publishRoute, async (c) => {
const { id } = c.req.valid("param");

const correction = await knowledgeCorrectionRepository.findById(c.env.DB, id);
if (!correction || correction.status === "retired") {
throw new HTTPException(404, { message: "訂正が見つかりません" });
}

await publishOrFail(c.env, correction);

const updated = await knowledgeCorrectionRepository.update(c.env.DB, id, {
status: "published",
});

return c.json(
{
message: "訂正を再発行しました",
correction: toCorrectionResponse(updated),
},
200,
);
});

const republishRoute = createRoute({
method: "post",
path: "/republish",
Expand Down
Loading
Loading