diff --git a/server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql b/server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql new file mode 100644 index 00000000..b537b10c --- /dev/null +++ b/server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql @@ -0,0 +1,51 @@ +-- 0023: Migrate legacy personal pages (`pages.note_id IS NULL`) into each owner's +-- default note, promote `pages.note_id` to NOT NULL, and drop `note_pages`. +-- +-- 0023: 旧個人ページ(`pages.note_id IS NULL`)を所有者のデフォルトノートへ移し、 +-- `pages.note_id` を NOT NULL に昇格し、`note_pages` を DROP する。 +-- +-- Idempotent / re-run safety: INSERT uses NOT EXISTS guards plus +-- ON CONFLICT aligned with partial unique index `idx_notes_unique_default_per_owner`; +-- DELETE targets orphans only. + +-- 1) Orphan personal pages whose owner row no longer exists — delete before NOT NULL +DELETE FROM "pages" +WHERE "note_id" IS NULL + AND "owner_id" NOT IN (SELECT "id" FROM "user"); +--> statement-breakpoint + +-- 2) Safety net: ensure users who still have NULL note_id rows have a default note +INSERT INTO "notes" ("owner_id", "title", "visibility", "edit_permission", "is_default") +SELECT u."id", COALESCE(u."name", '') || 'のノート', 'private', 'owner_only', true +FROM "user" u +WHERE EXISTS ( + SELECT 1 FROM "pages" p + WHERE p."owner_id" = u."id" AND p."note_id" IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM "notes" n + WHERE n."owner_id" = u."id" + AND n."is_default" = true + AND n."is_deleted" = false + ) +ON CONFLICT ("owner_id") WHERE ("is_default" = true AND "is_deleted" = false) DO NOTHING; +--> statement-breakpoint + +-- 3) Backfill personal pages into the owner's default note +UPDATE "pages" p +SET "note_id" = ( + SELECT n."id" FROM "notes" n + WHERE n."owner_id" = p."owner_id" + AND n."is_default" = true + AND n."is_deleted" = false + LIMIT 1 +) +WHERE p."note_id" IS NULL; +--> statement-breakpoint + +-- 4) Promote to NOT NULL +ALTER TABLE "pages" ALTER COLUMN "note_id" SET NOT NULL; +--> statement-breakpoint + +-- 5) Drop link table (single membership model — Issue #823) +DROP TABLE IF EXISTS "note_pages"; diff --git a/server/api/drizzle/meta/_journal.json b/server/api/drizzle/meta/_journal.json index b334f78a..5a97411d 100644 --- a/server/api/drizzle/meta/_journal.json +++ b/server/api/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1778544000000, "tag": "0022_add_default_note", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1778630400000, + "tag": "0023_migrate_personal_pages_drop_note_pages", + "breakpoints": true } ] } diff --git a/server/api/src/__tests__/routes/media.test.ts b/server/api/src/__tests__/routes/media.test.ts index ce8fdc9f..4c7721fd 100644 --- a/server/api/src/__tests__/routes/media.test.ts +++ b/server/api/src/__tests__/routes/media.test.ts @@ -46,11 +46,15 @@ vi.mock("@aws-sdk/client-s3", () => { function MockDeleteObjectCommand() { /* stub */ } + function MockHeadObjectCommand() { + /* stub — /confirm での所有権確認用 Head。Ownership probe on POST /confirm. */ + } return { S3Client: MockS3Client, PutObjectCommand: MockPutObjectCommand, GetObjectCommand: MockGetObjectCommand, DeleteObjectCommand: MockDeleteObjectCommand, + HeadObjectCommand: MockHeadObjectCommand, }; }); @@ -86,6 +90,9 @@ function createMediaApp(dbResults: unknown[]) { beforeEach(() => { mockS3Send.mockReset(); + // POST /confirm は常に HeadObject でプローブする。別レスポンスが必要なテストは mockResolvedValueOnce を使う。 + // POST /confirm always probes via HeadObject; tests needing other shapes use mockResolvedValueOnce. + mockS3Send.mockResolvedValue({ ContentLength: 1024 }); }); describe("POST /api/media/confirm — S3 key ownership validation", () => { diff --git a/server/api/src/__tests__/routes/notes/crud.test.ts b/server/api/src/__tests__/routes/notes/crud.test.ts index dedf1192..c2eb89a9 100644 --- a/server/api/src/__tests__/routes/notes/crud.test.ts +++ b/server/api/src/__tests__/routes/notes/crud.test.ts @@ -425,9 +425,7 @@ describe("GET /api/notes/:noteId", () => { expect(page).toHaveProperty("source_page_id"); expect(page).toHaveProperty("content_preview"); expect(page).toHaveProperty("thumbnail_url"); - expect(page).toHaveProperty("sort_order"); - expect(page).toHaveProperty("added_by_user_id"); - expect(page).toHaveProperty("added_at"); + expect(page).toHaveProperty("note_id", mockNote.id); }); it("should return 404 for non-existent note", async () => { diff --git a/server/api/src/__tests__/routes/notes/pages.test.ts b/server/api/src/__tests__/routes/notes/pages.test.ts index 2c2ac627..d4026340 100644 --- a/server/api/src/__tests__/routes/notes/pages.test.ts +++ b/server/api/src/__tests__/routes/notes/pages.test.ts @@ -1,5 +1,6 @@ /** - * ノートページ管理ルートのテスト + * ノートページ管理ルートのテスト(Issue #823: pages.note_id 直接モデル) + * Tests for note page routes after issue #823 (`pages.note_id` ownership). */ import { describe, it, expect, vi } from "vitest"; import type { Context, Next } from "hono"; @@ -26,6 +27,7 @@ vi.mock("../../../middleware/auth.js", () => ({ import { TEST_USER_ID, OTHER_USER_ID, + TEST_USER_EMAIL, createMockNote, createMockPageListRow, createTestApp, @@ -34,79 +36,41 @@ import { const NOTE_ID = "note-test-001"; -// ── POST /api/notes/:noteId/pages ─────────────────────────────────────────── - describe("POST /api/notes/:noteId/pages", () => { - it("should add a page and return { added: true, sort_order }", async () => { - const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole → findActiveNoteById (owner) - [{ id: "pg-new", ownerId: TEST_USER_ID, noteId: null }], // page exists check - [{ max: 2 }], // maxOrder query - [], // insert notePages - [], // update notes.updatedAt - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ page_id: "pg-new" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toEqual({ added: true, sort_order: 3 }); - }); - - it("should use provided sort_order when specified", async () => { + it("returns 400 when page_id linking is attempted (issue #823)", async () => { const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], - [{ id: "pg-new", ownerId: TEST_USER_ID, noteId: null }], - [{ max: 5 }], - [], - [], - ]); + const { app } = createTestApp([[mockNote]]); const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "POST", headers: authHeaders(), - body: JSON.stringify({ page_id: "pg-new", sort_order: 10 }), + body: JSON.stringify({ page_id: "pg-any" }), }); - const body = (await res.json()) as Record; - expect(body).toEqual({ added: true, sort_order: 10 }); + expect(res.status).toBe(400); + const text = await res.text(); + expect(text).toContain("page_id linking is removed"); }); - it("should accept camelCase pageId as alias for page_id", async () => { + it("returns 400 when pageId camelCase alias is used", async () => { const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole → findActiveNoteById (owner) - [{ id: "pg-camel", ownerId: TEST_USER_ID, noteId: null }], // page exists check - [{ max: 0 }], // maxOrder query - [], // insert notePages - [], // update notes.updatedAt - ]); + const { app } = createTestApp([[mockNote]]); const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "POST", headers: authHeaders(), - body: JSON.stringify({ pageId: "pg-camel" }), + body: JSON.stringify({ pageId: "pg-any" }), }); - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toHaveProperty("added", true); + expect(res.status).toBe(400); }); - it("should create a note-native page when title is provided without page_id (issue #713)", async () => { + it("creates a page from title and returns created + sort_order 0", async () => { const mockNote = createMockNote(); const { app, chains } = createTestApp([ - [mockNote], // getNoteRole - [{ id: "pg-created" }], // insert pages → returning (inside transaction) - [{ max: 0 }], // maxOrder query (inside transaction) - [], // insert notePages (inside transaction) - [], // update notes.updatedAt (inside transaction) + [mockNote], + [{ id: "pg-created", ownerId: TEST_USER_ID, noteId: NOTE_ID, title: "New Page" }], + [], ]); const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { @@ -117,17 +81,15 @@ describe("POST /api/notes/:noteId/pages", () => { expect(res.status).toBe(200); const body = (await res.json()) as Record; - expect(body).toHaveProperty("added", true); - expect(body).toHaveProperty("sort_order"); - - const insertCalls = chains.filter((c) => c.startMethod === "insert"); - const pageInsert = insertCalls[0]; - expect(pageInsert).toBeDefined(); - const valuesOp = pageInsert?.ops.find((op) => op.method === "values"); - // タイトル経路ではノートネイティブページとして作成されるため `noteId` が - // 必ず埋まり、個人 /home(`note_id IS NULL` フィルタ)には現れない。 - // The title path creates a note-native page; `noteId` must be set so it - // never appears on personal /home (which filters `note_id IS NULL`). + expect(body).toMatchObject({ + created: true, + page_id: "pg-created", + sort_order: 0, + }); + + const insertChains = chains.filter((c) => c.startMethod === "insert"); + expect(insertChains.length).toBeGreaterThanOrEqual(1); + const valuesOp = insertChains[0]?.ops.find((op) => op.method === "values"); expect(valuesOp?.args[0]).toMatchObject({ ownerId: TEST_USER_ID, noteId: NOTE_ID, @@ -135,40 +97,9 @@ describe("POST /api/notes/:noteId/pages", () => { }); }); - it("should NOT set noteId when linking an existing personal page via page_id (issue #713)", async () => { - const mockNote = createMockNote(); - const { app, chains } = createTestApp([ - [mockNote], // getNoteRole → findActiveNoteById (owner) - [{ id: "pg-existing", ownerId: TEST_USER_ID, noteId: null }], // page exists check - [{ max: 0 }], // maxOrder query - [], // insert notePages - [], // update notes.updatedAt - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ page_id: "pg-existing" }), - }); - - expect(res.status).toBe(200); - // `page_id` 経路は既存個人ページをノートに「リンク」するだけで、ページ - // 自体のスコープは変わらない。Phase 1 では pages テーブルへの insert は - // 走らない(note_pages へのリンクのみ)。Phase 3 で copy エンドポイント - // を別途追加する。 - // The `page_id` path only links an existing personal page into the note; - // the page itself stays personal. In Phase 1 there is no insert into the - // `pages` table — only the `note_pages` link row is touched. Phase 3 - // will add a separate copy endpoint. - const insertCalls = chains.filter((c) => c.startMethod === "insert"); - expect(insertCalls).toHaveLength(1); // note_pages link only, no pages insert - }); - - it("should return 400 when neither page_id nor title is provided", async () => { + it("returns 400 when title is missing", async () => { const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole - ]); + const { app } = createTestApp([[mockNote]]); const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "POST", @@ -179,581 +110,87 @@ describe("POST /api/notes/:noteId/pages", () => { expect(res.status).toBe(400); }); - it("should return 400 when title is empty string", async () => { + it("returns 400 when title is empty", async () => { const mockNote = createMockNote(); const { app } = createTestApp([[mockNote]]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { + const resEmpty = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ title: "" }), }); - - expect(res.status).toBe(400); - const text = await res.text(); - expect(text).toContain("title must be a non-empty string"); + expect(resEmpty.status).toBe(400); }); - it("should return 400 when title is whitespace-only", async () => { + it("returns 400 when title is whitespace-only", async () => { const mockNote = createMockNote(); const { app } = createTestApp([[mockNote]]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { + const resWs = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ title: " " }), }); - - expect(res.status).toBe(400); - const text = await res.text(); - expect(text).toContain("title must be a non-empty string"); + expect(resWs.status).toBe(400); }); - it("should return 400 when title is not a string", async () => { - const mockNote = createMockNote(); - const { app } = createTestApp([[mockNote]]); + it("returns 403 when caller cannot edit the note", async () => { + const mockNote = createMockNote({ ownerId: OTHER_USER_ID, visibility: "private" }); + const { app } = createTestApp([[mockNote], [], []]); const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "POST", - headers: authHeaders(), - body: JSON.stringify({ title: 123 }), - }); - - expect(res.status).toBe(400); - const text = await res.text(); - expect(text).toContain("title must be a non-empty string"); - }); - - it("should return 404 when page does not exist", async () => { - const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole - [], // page exists check → empty - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ page_id: "nonexistent" }), - }); - - expect(res.status).toBe(404); - }); - - it("should return 400 when page_id refers to a note-native page (issue #713)", async () => { - // 別ノートに所属するノートネイティブページを `page_id` 経由で別ノートに - // リンクできてしまうと壊れたカード(list には出るが open すると 403)に - // なるため拒否する。Phase 1 では個人ページ(`note_id IS NULL`)のみ - // リンク可能。Phase 3 のコピーエンドポイントで取り込みを実装する。 - // - // Reject note-native pages on the `page_id` link path (issue #713). - // Otherwise a page already scoped to note A would surface in note B but - // remain unauthorized for B's members. Only personal pages are linkable. - const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole (owner) - [{ id: "pg-native", ownerId: TEST_USER_ID, noteId: "another-note-id" }], // page exists, but note-native - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ page_id: "pg-native" }), - }); - - expect(res.status).toBe(400); - const text = await res.text(); - expect(text).toContain("Only personal pages can be linked"); - }); - - it("should return 403 when user has no edit permission", async () => { - const privateNote = createMockNote({ - ownerId: OTHER_USER_ID, - visibility: "private", - }); - const { app } = createTestApp([ - [privateNote], // getNoteRole → findActiveNoteById (not owner) - [], // getNoteRole → member check (not a member, private → null) - [], // getNoteRole → domain access check (no matching rule) - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ page_id: "pg-new" }), + headers: authHeaders(TEST_USER_ID, TEST_USER_EMAIL), + body: JSON.stringify({ title: "Nope" }), }); expect(res.status).toBe(403); }); - - it("should return 401 without auth", async () => { - const { app } = createTestApp([]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ page_id: "pg-new" }), - }); - - expect(res.status).toBe(401); - }); }); -// ── POST /api/notes/:noteId/pages/copy-from-personal/:pageId ──────────────── - -describe("POST /api/notes/:noteId/pages/copy-from-personal/:pageId (issue #713 Phase 3)", () => { - const SOURCE_PAGE_ID = "pg-source-personal"; - - it("should copy a personal page into the note as a note-native page with sourcePageId set", async () => { - const mockNote = createMockNote(); - const createdAt = new Date("2026-04-23T00:00:00Z"); - const updatedAt = new Date("2026-04-23T00:00:01Z"); - const { app, chains } = createTestApp([ - [mockNote], // getNoteRole → owner - [ - { - id: SOURCE_PAGE_ID, - ownerId: TEST_USER_ID, - noteId: null, - title: "Source Title", - contentPreview: "preview", - thumbnailUrl: "https://example.com/thumb.png", - sourceUrl: "https://example.com/src", - }, - ], // source page lookup - [ - { - id: "pg-copy-001", - ownerId: TEST_USER_ID, - noteId: NOTE_ID, - sourcePageId: SOURCE_PAGE_ID, - title: "Source Title", - contentPreview: "preview", - thumbnailUrl: "https://example.com/thumb.png", - sourceUrl: "https://example.com/src", - createdAt, - updatedAt, - isDeleted: false, - }, - ], // insert pages → returning (full row, used in response payload) - [{ ydocState: Buffer.from([1, 2, 3]), contentText: "body text" }], // source page_contents lookup - [], // insert page_contents for new page - [{ max: 4 }], // maxOrder notePages - [], // insert notePages - [], // update notes.updatedAt - ]); - - const res = await app.request( - `/api/notes/${NOTE_ID}/pages/copy-from-personal/${SOURCE_PAGE_ID}`, - { - method: "POST", - headers: authHeaders(), - }, - ); - - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toMatchObject({ created: true, page_id: "pg-copy-001", sort_order: 5 }); - // レスポンスにはクライアントが IDB に書き戻せる完全な新ページ行を含める - // (issue #713 Phase 3 / Codex P1)。note_id は destination ノートを指す。 - // The response carries the full new page row so clients can write through - // to IDB without a follow-up round trip. Issue #713 Phase 3 / Codex P1. - expect(body).toHaveProperty("page"); - expect(body.page).toMatchObject({ - id: "pg-copy-001", - owner_id: TEST_USER_ID, - note_id: NOTE_ID, - source_page_id: SOURCE_PAGE_ID, - title: "Source Title", - is_deleted: false, - }); - - // コピーされた pages 行は destination ノート下のノートネイティブページで、 - // `sourcePageId` が出自を指す。`noteId = NULL` のまま個人ホームに漏れない。 - // The copied pages row is a note-native page under the destination note, - // with `sourcePageId` recording provenance. It never leaks to personal /home. - const insertCalls = chains.filter((c) => c.startMethod === "insert"); - const pagesInsert = insertCalls[0]; - const pagesValues = pagesInsert?.ops.find((op) => op.method === "values"); - expect(pagesValues?.args[0]).toMatchObject({ - ownerId: TEST_USER_ID, - noteId: NOTE_ID, - sourcePageId: SOURCE_PAGE_ID, - title: "Source Title", - contentPreview: "preview", - thumbnailUrl: "https://example.com/thumb.png", - sourceUrl: "https://example.com/src", - }); - - // page_contents も同一トランザクションで新ページに複製される。 - // The page_contents row is duplicated into the new page in the same tx. - const contentsInsert = insertCalls[1]; - const contentsValues = contentsInsert?.ops.find((op) => op.method === "values"); - expect(contentsValues?.args[0]).toMatchObject({ - pageId: "pg-copy-001", - version: 1, - contentText: "body text", - }); - // Y.js 本文(バイナリ)が欠落すると `contentText` だけ正しく見えてしまうので、 - // `ydocState` が元と一致することも明示的に検証する(CodeRabbit Major 指摘)。 - // Verify the binary Y.js state is preserved too — otherwise a regression - // that drops the buffer would silently pass because `contentText` copies - // correctly. (CodeRabbit Major.) - expect((contentsValues?.args[0] as { ydocState: Buffer }).ydocState).toEqual( - Buffer.from([1, 2, 3]), - ); - }); - - it("should skip page_contents insert when the source page has no content row yet", async () => { - const mockNote = createMockNote(); - const { app, chains } = createTestApp([ - [mockNote], // getNoteRole - [ - { - id: SOURCE_PAGE_ID, - ownerId: TEST_USER_ID, - noteId: null, - title: "Empty Page", - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - }, - ], // source page - [ - { - id: "pg-copy-empty", - ownerId: TEST_USER_ID, - noteId: NOTE_ID, - sourcePageId: SOURCE_PAGE_ID, - title: "Empty Page", - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - createdAt: new Date("2026-04-23T00:00:00Z"), - updatedAt: new Date("2026-04-23T00:00:00Z"), - isDeleted: false, - }, - ], // insert pages → returning (full row) - [], // source page_contents lookup → empty - [{ max: 0 }], // maxOrder - [], // insert notePages - [], // update notes - ]); - - const res = await app.request( - `/api/notes/${NOTE_ID}/pages/copy-from-personal/${SOURCE_PAGE_ID}`, - { - method: "POST", - headers: authHeaders(), - }, - ); - - expect(res.status).toBe(200); - - // page_contents への insert は走らない(元行なし)。insert は pages + notePages の 2 回だけ。 - // No page_contents insert when source has none; total inserts = pages + notePages. - const insertCalls = chains.filter((c) => c.startMethod === "insert"); - expect(insertCalls).toHaveLength(2); - }); - - it("should return 403 when caller is not the owner of the source personal page", async () => { - const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole → owner of note - [ - { - id: SOURCE_PAGE_ID, - ownerId: OTHER_USER_ID, // someone else's personal page - noteId: null, - title: "Not Yours", - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - }, - ], - ]); - - const res = await app.request( - `/api/notes/${NOTE_ID}/pages/copy-from-personal/${SOURCE_PAGE_ID}`, - { - method: "POST", - headers: authHeaders(), - }, - ); - - expect(res.status).toBe(403); - }); - - it("should return 400 when the source page is note-native (not a personal page)", async () => { +describe("GET /api/notes/:noteId/pages", () => { + it("lists pages filtered by pages.note_id ordered by updated_at desc", async () => { const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole - [ - { - id: SOURCE_PAGE_ID, - ownerId: TEST_USER_ID, - noteId: "another-note-id", // already note-native - title: "Note-Native", - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - }, - ], - ]); - - const res = await app.request( - `/api/notes/${NOTE_ID}/pages/copy-from-personal/${SOURCE_PAGE_ID}`, - { - method: "POST", - headers: authHeaders(), - }, - ); - - expect(res.status).toBe(400); - const text = await res.text(); - expect(text).toContain("personal page"); - }); - - it("should return 403 when caller cannot edit the destination note", async () => { - const privateNote = createMockNote({ - ownerId: OTHER_USER_ID, - visibility: "private", + const row1 = createMockPageListRow({ page_id: "pg-1", page_title: "First" }); + const row2 = createMockPageListRow({ + page_id: "pg-2", + page_title: "Second", + page_updated_at: new Date("2026-02-01T00:00:00Z"), }); - const { app } = createTestApp([ - [privateNote], // findActiveNoteById - [], // member check - [], // domain access - ]); - - const res = await app.request( - `/api/notes/${NOTE_ID}/pages/copy-from-personal/${SOURCE_PAGE_ID}`, - { - method: "POST", - headers: authHeaders(), - }, - ); - - expect(res.status).toBe(403); - }); -}); - -// ── POST /api/notes/:noteId/pages/:pageId/copy-to-personal ────────────────── - -describe("POST /api/notes/:noteId/pages/:pageId/copy-to-personal (issue #713 Phase 3)", () => { - const NOTE_PAGE_ID = "pg-note-native"; + const { app } = createTestApp([[mockNote], [row1, row2]]); - it("should copy a note-native page into the caller's personal pages (noteId = NULL, sourcePageId set)", async () => { - const mockNote = createMockNote(); - const createdAt = new Date("2026-04-23T00:00:00Z"); - const updatedAt = new Date("2026-04-23T00:00:01Z"); - const { app, chains } = createTestApp([ - [mockNote], // getNoteRole → owner of the note - [ - { - id: NOTE_PAGE_ID, - noteId: NOTE_ID, - title: "Shared Note Page", - contentPreview: "snippet", - thumbnailUrl: null, - sourceUrl: null, - }, - ], // source page - [ - { - id: "pg-personal-copy", - ownerId: TEST_USER_ID, - noteId: null, - sourcePageId: NOTE_PAGE_ID, - title: "Shared Note Page", - contentPreview: "snippet", - thumbnailUrl: null, - sourceUrl: null, - createdAt, - updatedAt, - isDeleted: false, - }, - ], // insert pages → returning (full row, used in response payload) - [{ ydocState: Buffer.from([9, 9]), contentText: "note body" }], // source page_contents - [], // insert page_contents for new page - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages/${NOTE_PAGE_ID}/copy-to-personal`, { - method: "POST", + const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { + method: "GET", headers: authHeaders(), }); expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toMatchObject({ created: true, page_id: "pg-personal-copy" }); - // レスポンスに完全な新ページ行が含まれ、クライアントは IDB に書き戻せる - // (issue #713 Phase 3 / Codex P1)。個人ページなので note_id は null。 - // The full new page row is carried so the client can write it through to - // IDB immediately. Issue #713 Phase 3 / Codex P1. Personal page → note_id is null. - expect(body.page).toMatchObject({ - id: "pg-personal-copy", - owner_id: TEST_USER_ID, - note_id: null, - source_page_id: NOTE_PAGE_ID, - title: "Shared Note Page", - content_preview: "snippet", - is_deleted: false, - }); - - // 個人ページとして作成されるため `noteId` は明示的に `null`(個人スコープ)で、 - // `sourcePageId` に出自のノートネイティブページ ID が入る。 - // Creates a personal page: `noteId` is explicitly `null` (personal scope), - // with `sourcePageId` pointing back to the source note-native page. - const insertCalls = chains.filter((c) => c.startMethod === "insert"); - const pagesInsert = insertCalls[0]; - const pagesValues = pagesInsert?.ops.find((op) => op.method === "values"); - const values = pagesValues?.args[0] as Record; - expect(values).toMatchObject({ - ownerId: TEST_USER_ID, - sourcePageId: NOTE_PAGE_ID, - title: "Shared Note Page", - contentPreview: "snippet", - }); - // `noteId` must be null for personal pages (`note_id IS NULL` filter relies on this). - expect(values.noteId).toBeNull(); - - // Y.js 本文(`ydocState`)も元からコピー先にそのまま移ることを保証する。 - // バイナリが欠落してもテキストだけ正しく見えてしまう回帰を防ぐ(CodeRabbit Major)。 - // Assert the binary Y.js state is also carried to the destination — this - // guards against regressions that drop the buffer while `contentText` - // still copies correctly. (CodeRabbit Major.) - const contentsInsert = insertCalls[1]; - const contentsValues = contentsInsert?.ops.find((op) => op.method === "values"); - expect(contentsValues?.args[0]).toMatchObject({ - pageId: "pg-personal-copy", - version: 1, - contentText: "note body", - }); - expect((contentsValues?.args[0] as { ydocState: Buffer }).ydocState).toEqual( - Buffer.from([9, 9]), - ); - - // 個人ページはノートリストに入らないので `notePages` / `notes.updatedAt` は触らない。 - // Personal copies do not join the note list, so no notePages/notes update. - expect(insertCalls).toHaveLength(2); // pages + page_contents only - const updateCalls = chains.filter((c) => c.startMethod === "update"); - expect(updateCalls).toHaveLength(0); - }); - - it("should return 400 when the source page belongs to a different note", async () => { - const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole - [ - { - id: NOTE_PAGE_ID, - noteId: "other-note-id", // mismatch with URL noteId - title: "Foreign Page", - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - }, - ], - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages/${NOTE_PAGE_ID}/copy-to-personal`, { - method: "POST", - headers: authHeaders(), - }); - - expect(res.status).toBe(400); - const text = await res.text(); - expect(text).toContain("does not belong"); + const body = (await res.json()) as { pages: Array> }; + expect(body.pages).toHaveLength(2); + expect(body.pages[0]).toMatchObject({ page_id: "pg-1", page_title: "First" }); + expect(body.pages[0]).not.toHaveProperty("sort_order"); }); - it("should return 403 when caller has no role on the note (e.g. private / not a member)", async () => { - const privateNote = createMockNote({ - ownerId: OTHER_USER_ID, - visibility: "private", - }); - const { app } = createTestApp([ - [privateNote], // findActiveNoteById - [], // member check - [], // domain access - ]); + it("returns 403 when caller has no note role", async () => { + const mockNote = createMockNote({ ownerId: OTHER_USER_ID, visibility: "private" }); + const { app } = createTestApp([[mockNote], [], []]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages/${NOTE_PAGE_ID}/copy-to-personal`, { - method: "POST", - headers: authHeaders(), + const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { + method: "GET", + headers: authHeaders(TEST_USER_ID, TEST_USER_EMAIL), }); expect(res.status).toBe(403); }); - - it("should succeed for a guest role on public notes (any resolved role may copy to personal)", async () => { - // 公開ノートでは `role = 'guest'` として解決されるため、閲覧できる以上 - // 個人コピーも許可する。脱退後も各自の個人コピーは残る、という仕様を反映。 - // Public notes resolve the caller to `guest`; since they can already view, - // they are allowed to take a personal copy. Matches the spec that personal - // copies outlive any later membership change. - const publicNote = createMockNote({ - ownerId: OTHER_USER_ID, - visibility: "public", - }); - const { app, chains } = createTestApp([ - [publicNote], // getNoteRole → findActiveNoteById - [], // getNoteRole → member check (not a member) - [], // getNoteRole → domain access check (no matching rule) - [ - { - id: NOTE_PAGE_ID, - noteId: NOTE_ID, - title: "Public Page", - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - }, - ], // source page lookup inside tx - [ - { - id: "pg-guest-copy", - ownerId: TEST_USER_ID, - noteId: null, - sourcePageId: NOTE_PAGE_ID, - title: "Public Page", - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - createdAt: new Date("2026-04-23T00:00:00Z"), - updatedAt: new Date("2026-04-23T00:00:00Z"), - isDeleted: false, - }, - ], // insert pages → returning (full row) - [], // source page_contents lookup → empty - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages/${NOTE_PAGE_ID}/copy-to-personal`, { - method: "POST", - headers: authHeaders(), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toMatchObject({ created: true, page_id: "pg-guest-copy" }); - - // 元の note 側は更新しない:個人コピーは完全に独立したエンティティ。 - // The source note is untouched: the personal copy is wholly independent. - expect(chains.filter((c) => c.startMethod === "update")).toHaveLength(0); - }); }); -// ── DELETE /api/notes/:noteId/pages/:pageId ───────────────────────────────── - describe("DELETE /api/notes/:noteId/pages/:pageId", () => { - it("should detach a personal page (note_id IS NULL) without deleting the pages row", async () => { + it("soft-deletes page when it belongs to the note", async () => { const mockNote = createMockNote(); - const { app, chains } = createTestApp([ - [mockNote], // getNoteRole (owner) - [{ id: "pg-001", noteId: null }], // page lookup inside tx - [], // update notePages (soft delete) - [], // update notes.updatedAt - ]); + const pageId = "pg-del-1"; + const { app, chains } = createTestApp([[mockNote], [{ id: pageId, noteId: NOTE_ID }], [], []]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages/pg-001`, { + const res = await app.request(`/api/notes/${NOTE_ID}/pages/${pageId}`, { method: "DELETE", headers: authHeaders(), }); @@ -762,181 +199,91 @@ describe("DELETE /api/notes/:noteId/pages/:pageId", () => { const body = (await res.json()) as Record; expect(body).toEqual({ removed: true }); - // 個人ページは `note_pages` リンクと `notes.updatedAt` だけ更新する。 - // `pages` 自体は所有者の個人 /home に残るので update しない。 - // Personal page: only `note_pages` and `notes.updatedAt` get updated; - // the `pages` row itself stays alive on the owner's /home. - const updateCalls = chains.filter((c) => c.startMethod === "update"); - expect(updateCalls).toHaveLength(2); + const updates = chains.filter((c) => c.startMethod === "update"); + expect(updates.length).toBe(2); }); - it("should also tombstone the pages row when removing a note-native page (issue #713)", async () => { - // ノートネイティブページ(`pages.note_id = noteId`)を `note_pages` だけ - // 論理削除すると `pages` 行が孤児として残り、`/api/pages/:id/content` が - // ノートロール経由で引き続き認可してしまう。同じトランザクションで - // `pages.is_deleted = true` まで進めることを検証する。 - // - // For note-native pages, tombstoning only `note_pages` would leave the - // `pages` row alive and still authorized via the note role. Verify the - // route updates `pages.is_deleted = true` in the same transaction. + it("returns 400 when page belongs to another note", async () => { const mockNote = createMockNote(); - const { app, chains } = createTestApp([ - [mockNote], // getNoteRole (owner) - [{ id: "pg-native", noteId: NOTE_ID }], // page lookup → note-native - [], // update notePages (soft delete) - [], // update pages (soft delete the orphan) - [], // update notes.updatedAt - ]); + const pageId = "pg-other-note"; + const { app } = createTestApp([[mockNote], [{ id: pageId, noteId: "other-note-id" }]]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages/pg-native`, { + const res = await app.request(`/api/notes/${NOTE_ID}/pages/${pageId}`, { method: "DELETE", headers: authHeaders(), }); - expect(res.status).toBe(200); - - const updateCalls = chains.filter((c) => c.startMethod === "update"); - expect(updateCalls).toHaveLength(3); // note_pages + pages + notes + expect(res.status).toBe(400); }); - it("should return 403 when user cannot edit", async () => { - const privateNote = createMockNote({ - ownerId: OTHER_USER_ID, - visibility: "private", - }); - const { app } = createTestApp([ - [privateNote], // getNoteRole (not owner) - [], // member check (not a member) - [], // domain access check (no matching rule) - ]); + it("returns 404 when page id missing", async () => { + const mockNote = createMockNote(); + const { app } = createTestApp([[mockNote], []]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages/pg-001`, { + const res = await app.request(`/api/notes/${NOTE_ID}/pages/missing-page`, { method: "DELETE", headers: authHeaders(), }); - expect(res.status).toBe(403); + expect(res.status).toBe(404); }); }); -// ── PUT /api/notes/:noteId/pages (reorder) ────────────────────────────────── - -describe("PUT /api/notes/:noteId/pages", () => { - it("should reorder pages and return { reordered: true }", async () => { +describe("PUT /api/notes/:noteId/pages (reorder noop)", () => { + it("returns reordered true and only bumps notes.updated_at", async () => { const mockNote = createMockNote(); - const { app, chains } = createTestApp([ - [mockNote], // getNoteRole (owner) - [], // update notePages for page_ids[0] - [], // update notePages for page_ids[1] - [], // update notes.updatedAt - ]); + const { app, chains } = createTestApp([[mockNote], []]); const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "PUT", headers: authHeaders(), - body: JSON.stringify({ page_ids: ["pg-b", "pg-a"] }), + body: JSON.stringify({ page_ids: ["a", "b"] }), }); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body).toEqual({ reordered: true }); - const updateCalls = chains.filter((c) => c.startMethod === "update"); - expect(updateCalls.length).toBe(3); + const updates = chains.filter((c) => c.startMethod === "update"); + expect(updates).toHaveLength(1); }); - it("should return 400 when page_ids is missing", async () => { - const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - method: "PUT", - headers: authHeaders(), - body: JSON.stringify({}), - }); - - expect(res.status).toBe(400); - }); - - it("should return 400 when page_ids is empty", async () => { + it("returns 400 when page_ids missing", async () => { const mockNote = createMockNote(); const { app } = createTestApp([[mockNote]]); const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { method: "PUT", headers: authHeaders(), - body: JSON.stringify({ page_ids: [] }), + body: JSON.stringify({}), }); expect(res.status).toBe(400); }); }); -// ── GET /api/notes/:noteId/pages ──────────────────────────────────────────── - -describe("GET /api/notes/:noteId/pages", () => { - it("should return pages in { pages: [...] } format", async () => { +describe("Removed routes (404)", () => { + it("copy-from-personal is not registered", async () => { const mockNote = createMockNote(); - const row1 = createMockPageListRow({ page_id: "pg-1", sort_order: 0 }); - const row2 = createMockPageListRow({ page_id: "pg-2", sort_order: 1, page_title: "Second" }); - - const { app } = createTestApp([ - [mockNote], // getNoteRole (owner) - [row1, row2], // select pages - ]); + const { app } = createTestApp([[mockNote]]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { + const res = await app.request(`/api/notes/${NOTE_ID}/pages/copy-from-personal/pg-x`, { + method: "POST", headers: authHeaders(), }); - expect(res.status).toBe(200); - const body = (await res.json()) as { pages: Record[] }; - - expect(body).toHaveProperty("pages"); - expect(body.pages).toHaveLength(2); - - const first = body.pages[0]; - if (!first) throw new Error("expected at least one page"); - expect(first).toHaveProperty("page_id", "pg-1"); - expect(first).toHaveProperty("sort_order", 0); - expect(first).toHaveProperty("added_by"); - expect(first).toHaveProperty("page_title"); - expect(first).toHaveProperty("page_content_preview"); - expect(first).toHaveProperty("page_thumbnail_url"); + expect(res.status).toBe(404); }); - it("should return empty array when note has no pages", async () => { + it("copy-to-personal is not registered", async () => { const mockNote = createMockNote(); - const { app } = createTestApp([ - [mockNote], // getNoteRole - [], // select pages → empty - ]); - - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { - headers: authHeaders(), - }); - - const body = (await res.json()) as { pages: unknown[] }; - expect(body.pages).toHaveLength(0); - }); - - it("should return 403 for private note accessed by non-member", async () => { - const privateNote = createMockNote({ - ownerId: OTHER_USER_ID, - visibility: "private", - }); - const { app } = createTestApp([ - [privateNote], // getNoteRole (not owner) - [], // member check (not a member) - [], // domain access check (no matching rule) - ]); + const { app } = createTestApp([[mockNote]]); - const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { + const res = await app.request(`/api/notes/${NOTE_ID}/pages/pg-x/copy-to-personal`, { + method: "POST", headers: authHeaders(), }); - expect(res.status).toBe(403); + expect(res.status).toBe(404); }); }); diff --git a/server/api/src/__tests__/routes/notes/search.test.ts b/server/api/src/__tests__/routes/notes/search.test.ts index ddda1eee..eab1eef5 100644 --- a/server/api/src/__tests__/routes/notes/search.test.ts +++ b/server/api/src/__tests__/routes/notes/search.test.ts @@ -105,12 +105,9 @@ describe("GET /api/notes/:noteId/search", () => { expect(chains.some((c) => c.startMethod === "execute")).toBe(false); }); - it("restricts SQL to pages joined via note_pages for this noteId", async () => { - // ノートスコープ: `note_pages.note_id = :noteId` の inner join で絞り込み、 - // 他ノートや個人 /home のページが混ざらないことを SQL レベルで担保する。 - // Scope guard at the SQL layer: inner join through `note_pages` with - // `note_id = :noteId` so pages from other notes (or personal /home) cannot - // leak into the results. + it("restricts SQL to pages where p.note_id matches path noteId (issue #823)", async () => { + // ノートスコープ: `pages.note_id = :noteId` で直接フィルタする(note_pages 廃止)。 + // Scope guard: filter `pages.note_id` to the path param (`note_pages` removed). const mockNote = createMockNote(); const { app, chains } = createTestApp([ [mockNote], // getNoteRole → owner @@ -126,11 +123,7 @@ describe("GET /api/notes/:noteId/search", () => { const executeChain = chains.find((chain) => chain.startMethod === "execute"); expect(executeChain).toBeDefined(); const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("note_pages"); - expect(serialised).toContain("np.note_id"); - // SELECT 句に p.note_id を含め、呼び出し側がネイティブ / リンク済み個人ページを - // 見分けられるようにする (Phase 5 契約)。 - // Expose `p.note_id` so callers can tell note-native vs linked personal. + expect(serialised).not.toContain("note_pages"); expect(serialised).toContain("p.note_id"); // ILIKE による全文検索パターンが使われていること。 // ILIKE search uses the escaped pattern. diff --git a/server/api/src/__tests__/routes/notes/setup.ts b/server/api/src/__tests__/routes/notes/setup.ts index 27695ee3..e355796a 100644 --- a/server/api/src/__tests__/routes/notes/setup.ts +++ b/server/api/src/__tests__/routes/notes/setup.ts @@ -44,6 +44,7 @@ export function createMockPageRow(overrides: Record = {}) { return { id: "page-test-001", ownerId: TEST_USER_ID, + noteId: "note-test-001", sourcePageId: null, title: "Test Page", contentPreview: "Preview content...", @@ -52,9 +53,6 @@ export function createMockPageRow(overrides: Record = {}) { createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), isDeleted: false, - sortOrder: 0, - addedByUserId: TEST_USER_ID, - addedAt: new Date("2026-01-01T00:00:00Z"), ...overrides, }; } @@ -63,8 +61,6 @@ export function createMockPageRow(overrides: Record = {}) { export function createMockPageListRow(overrides: Record = {}) { return { page_id: "page-test-001", - sort_order: 0, - added_by: TEST_USER_ID, page_title: "Test Page", page_content_preview: "Preview...", page_thumbnail_url: null, diff --git a/server/api/src/__tests__/routes/pageSnapshots.test.ts b/server/api/src/__tests__/routes/pageSnapshots.test.ts index 62556db6..7be47cd1 100644 --- a/server/api/src/__tests__/routes/pageSnapshots.test.ts +++ b/server/api/src/__tests__/routes/pageSnapshots.test.ts @@ -1,6 +1,12 @@ /** * pageSnapshots ルートのテスト(認可・CRUD) * Tests for page snapshots routes: authorization, list, detail, restore. + * + * Issue #823: `assertPageViewAccess` / `assertPageEditAccess` は `pages.note_id` 経由の + * ノートロールのみで判定する。モック DB は SELECT 連鎖をこの順に返す。 + * + * Issue #823: access checks use note roles via `pages.note_id` only; mocks must return + * SELECT chains in this order. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Context, Next } from "hono"; @@ -33,6 +39,30 @@ function authHeaders(userId: string = OWNER_ID) { }; } +function mockNote(noteOwnerId: string) { + return { + id: NOTE_ID, + ownerId: noteOwnerId, + title: "n", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + }; +} + +/** 1: pages row 2: caller email 3: findActiveNoteById */ +function viewAccessPrefix( + asUserEmail: string, + noteOwnerId: string, + pageRow: Record, +) { + return [[pageRow], [{ email: asUserEmail }], [mockNote(noteOwnerId)]]; +} + function createSnapshotsApp(dbResults: unknown[]) { const { db } = createMockDb(dbResults); const app = new Hono(); @@ -52,8 +82,6 @@ afterEach(() => { vi.unstubAllGlobals(); }); -// ── 認証 / Authentication ────────────────────────────────────────────────── - describe("Authentication", () => { it("returns 401 without auth header", async () => { const app = createSnapshotsApp([]); @@ -64,15 +92,12 @@ describe("Authentication", () => { }); }); -// ── GET /snapshots — 一覧 / List ──────────────────────────────────────────── - describe("GET /api/pages/:id/snapshots", () => { it("returns snapshots for page owner", async () => { const now = new Date(); + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // assertPageViewAccess: pages query - [{ id: PAGE_ID, ownerId: OWNER_ID }], - // snapshots query + ...viewAccessPrefix("owner@example.com", OWNER_ID, pageRow), [ { id: SNAPSHOT_ID, @@ -83,7 +108,6 @@ describe("GET /api/pages/:id/snapshots", () => { createdAt: now, }, ], - // users query (email resolution) [{ id: OWNER_ID, email: "owner@example.com" }], ]); @@ -96,12 +120,7 @@ describe("GET /api/pages/:id/snapshots", () => { const body = (await res.json()) as { snapshots: Array<{ id: string; - version: number; - content_text: string; - created_by: string; created_by_email: string; - trigger: string; - created_at: string; }>; }; expect(body.snapshots).toEqual([ @@ -113,10 +132,7 @@ describe("GET /api/pages/:id/snapshots", () => { }); it("returns 404 when page does not exist", async () => { - const app = createSnapshotsApp([ - // assertPageViewAccess: pages query returns empty - [], - ]); + const app = createSnapshotsApp([[]]); const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, { method: "GET", @@ -127,12 +143,10 @@ describe("GET /api/pages/:id/snapshots", () => { }); it("returns 403 when user is not owner and not a note member", async () => { + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // assertPageViewAccess: pages query - [{ id: PAGE_ID, ownerId: OWNER_ID }], - // user email lookup - [{ email: "other@example.com" }], - // notePages + noteMembers JOIN returns empty + ...viewAccessPrefix("other@example.com", OWNER_ID, pageRow), + [], [], ]); @@ -145,14 +159,10 @@ describe("GET /api/pages/:id/snapshots", () => { }); it("allows access for note member", async () => { + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // assertPageViewAccess: pages query (owner is different) - [{ id: PAGE_ID, ownerId: OWNER_ID }], - // user email lookup - [{ email: "member@example.com" }], - // notePages + noteMembers JOIN returns a match - [{ noteId: NOTE_ID }], - // snapshots query + ...viewAccessPrefix("member@example.com", OWNER_ID, pageRow), + [{ role: "viewer" }], [], ]); @@ -165,16 +175,13 @@ describe("GET /api/pages/:id/snapshots", () => { }); }); -// ── GET /snapshots/:snapshotId — 詳細 / Detail ───────────────────────────── - describe("GET /api/pages/:id/snapshots/:snapshotId", () => { it("returns snapshot detail for owner", async () => { const now = new Date(); const ydocBuffer = Buffer.from("test-ydoc"); + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // assertPageViewAccess: pages query - [{ id: PAGE_ID, ownerId: OWNER_ID }], - // snapshot query + ...viewAccessPrefix("owner@example.com", OWNER_ID, pageRow), [ { id: SNAPSHOT_ID, @@ -187,7 +194,6 @@ describe("GET /api/pages/:id/snapshots/:snapshotId", () => { createdAt: now, }, ], - // user email lookup for created_by [{ email: "owner@example.com" }], ]); @@ -208,10 +214,9 @@ describe("GET /api/pages/:id/snapshots/:snapshotId", () => { }); it("returns 404 when snapshot does not exist", async () => { + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // assertPageViewAccess: pages query - [{ id: PAGE_ID, ownerId: OWNER_ID }], - // snapshot query returns empty + ...viewAccessPrefix("owner@example.com", OWNER_ID, pageRow), [], ]); @@ -224,13 +229,13 @@ describe("GET /api/pages/:id/snapshots/:snapshotId", () => { }); }); -// ── POST /snapshots/:snapshotId/restore — 復元 / Restore ────────────────── - describe("POST /api/pages/:id/snapshots/:snapshotId/restore", () => { it("returns 403 when non-owner tries to restore", async () => { + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // page ownership check - [{ id: PAGE_ID, ownerId: OWNER_ID }], + ...viewAccessPrefix("other@example.com", OWNER_ID, pageRow), + [], + [], ]); const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, { @@ -242,10 +247,7 @@ describe("POST /api/pages/:id/snapshots/:snapshotId/restore", () => { }); it("returns 404 when page does not exist for restore", async () => { - const app = createSnapshotsApp([ - // page query returns empty - [], - ]); + const app = createSnapshotsApp([[]]); const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, { method: "POST", @@ -256,10 +258,9 @@ describe("POST /api/pages/:id/snapshots/:snapshotId/restore", () => { }); it("returns 404 when snapshot does not exist for restore", async () => { + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // page ownership check - [{ id: PAGE_ID, ownerId: OWNER_ID }], - // snapshot query returns empty + ...viewAccessPrefix("owner@example.com", OWNER_ID, pageRow), [], ]); @@ -273,10 +274,9 @@ describe("POST /api/pages/:id/snapshots/:snapshotId/restore", () => { it("restores snapshot and returns new version for owner", async () => { const ydocBuffer = Buffer.from("restored-ydoc"); + const pageRow = { id: PAGE_ID, ownerId: OWNER_ID, noteId: NOTE_ID }; const app = createSnapshotsApp([ - // page ownership check - [{ id: PAGE_ID, ownerId: OWNER_ID }], - // snapshot query + ...viewAccessPrefix("owner@example.com", OWNER_ID, pageRow), [ { id: SNAPSHOT_ID, @@ -289,19 +289,12 @@ describe("POST /api/pages/:id/snapshots/:snapshotId/restore", () => { createdAt: new Date(), }, ], - // transaction: row lock [{}], - // transaction: current content [{ version: 2, ydocState: Buffer.from("current"), contentText: "current" }], - // transaction: insert current snapshot [{}], - // transaction: update page_contents [{ version: 3, pageId: PAGE_ID }], - // transaction: insert restore snapshot [{ id: "snap-restore-001" }], - // transaction: update pages metadata [{}], - // transaction: prune old snapshots [{}], ]); diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index a36b3d67..4bcb0636 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -11,16 +11,49 @@ vi.mock("../../middleware/auth.js", () => ({ const userId = c.req.header("x-test-user-id"); if (!userId) return c.json({ message: "Unauthorized" }, 401); c.set("userId", userId); + c.set("userEmail", "tester@example.com"); await next(); }, })); +vi.mock("../../services/defaultNoteService.js", () => ({ + ensureDefaultNote: vi.fn(async (_db: unknown, userId: string) => ({ + id: "default-note-mock", + ownerId: userId, + title: "Mockのノート", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + isDefault: true, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + })), + getDefaultNoteOrNull: vi.fn(async (_db: unknown, userId: string) => ({ + id: "default-note-mock", + ownerId: userId, + title: "Mockのノート", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + isDefault: true, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + })), +})); + import { Hono } from "hono"; import pageRoutes from "../../routes/pages.js"; +import { ensureDefaultNote, getDefaultNoteOrNull } from "../../services/defaultNoteService.js"; import { createMockDb } from "../createMockDb.js"; const TEST_USER_ID = "user-test-123"; const PAGE_ID = "page-content-test-001"; +/** pages.note_id と findActiveNoteById が参照するノート ID を一致させる。 */ +const NOTE_ID = "note-access-test-001"; function authHeaders() { return { @@ -29,6 +62,31 @@ function authHeaders() { }; } +function mockNoteRow() { + return { + id: NOTE_ID, + ownerId: TEST_USER_ID, + title: "Test note", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + isDefault: false, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + }; +} + +/** PR 1b 以降の assertPage*Access が要求する SELECT 3 連を先頭に付ける。 */ +function pageAccessPrefix(extraPageFields: Record = {}) { + return [ + [{ id: PAGE_ID, ownerId: TEST_USER_ID, noteId: NOTE_ID, ...extraPageFields }], + [{ email: "tester@example.com" }], + [mockNoteRow()], + ]; +} + function createPagesApp(dbResults: unknown[]) { const { db } = createMockDb(dbResults); const app = new Hono(); @@ -53,7 +111,7 @@ function createPagesAppWithChains(dbResults: unknown[]) { describe("GET /api/pages/:id/content", () => { it("returns 200 with empty ydoc_state when page exists but page_contents row is missing", async () => { - const app = createPagesApp([[{ id: PAGE_ID, ownerId: TEST_USER_ID }], []]); + const app = createPagesApp([...pageAccessPrefix(), []]); const res = await app.request(`/api/pages/${PAGE_ID}/content`, { method: "GET", @@ -82,7 +140,7 @@ describe("GET /api/pages/:id/content", () => { }); it("returns 401 without auth header", async () => { - const app = createPagesApp([[], []]); + const app = createPagesApp([]); const res = await app.request(`/api/pages/${PAGE_ID}/content`, { method: "GET", @@ -93,180 +151,135 @@ describe("GET /api/pages/:id/content", () => { }); describe("GET /api/pages", () => { - it("returns 200 with paginated own pages by default", async () => { - const updatedAt = new Date("2026-01-01T00:00:00Z").toISOString(); - const { app, chains } = createPagesAppWithChains([ - { - rows: [ - { id: "page-a", title: "A", content_preview: null, updated_at: updatedAt }, - { id: "page-b", title: "B", content_preview: "preview", updated_at: updatedAt }, - ], - }, - ]); - - const res = await app.request("/api/pages?limit=2&offset=0", { - method: "GET", - headers: authHeaders(), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { pages: Array> }; - expect(body.pages).toHaveLength(2); - expect(body.pages[0]).toMatchObject({ id: "page-a", title: "A" }); - expect(body.pages[1]).toMatchObject({ id: "page-b", title: "B" }); - // 単一の execute 呼び出しで完結する。 - // The endpoint resolves with a single execute() call. - expect(chains.filter((c) => c.startMethod === "execute")).toHaveLength(1); - }); - - it("returns 200 with empty array when caller has no pages", async () => { - const app = createPagesApp([{ rows: [] }]); - - const res = await app.request("/api/pages", { - method: "GET", - headers: authHeaders(), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { pages: unknown[] }; - expect(body.pages).toEqual([]); - }); - - it("returns 200 with shared pages when scope=shared", async () => { - const updatedAt = new Date("2026-02-01T00:00:00Z").toISOString(); + it("returns 200 with Deprecation header and legacy listing shape (issue #823 shim)", async () => { + const updatedAt = new Date("2026-03-01T12:00:00Z"); const app = createPagesApp([ { rows: [ { - id: "page-shared", - title: "Shared", - content_preview: null, + id: "list-page-1", + title: "Hello", + content_preview: "pv", updated_at: updatedAt, + note_id: "default-note-mock", }, ], }, ]); - const res = await app.request("/api/pages?scope=shared", { + const res = await app.request("/api/pages", { method: "GET", headers: authHeaders(), }); expect(res.status).toBe(200); - const body = (await res.json()) as { pages: Array> }; + expect(res.headers.get("Deprecation")).toBe("true"); + const body = (await res.json()) as { + pages: Array<{ id: string; title: string; note_id: string }>; + }; expect(body.pages).toHaveLength(1); - expect(body.pages[0]).toMatchObject({ id: "page-shared" }); + expect(body.pages[0]?.id).toBe("list-page-1"); + expect(body.pages[0]?.note_id).toBe("default-note-mock"); }); - it("scope=shared predicate includes note-owner access through note_pages for linked personal pages too", async () => { - const { app, chains } = createPagesAppWithChains([{ rows: [] }]); + it("returns empty pages when default note is missing (no listing)", async () => { + vi.mocked(getDefaultNoteOrNull).mockResolvedValueOnce(null); + const app = createPagesApp([]); - const res = await app.request("/api/pages?scope=shared", { + const res = await app.request("/api/pages", { method: "GET", headers: authHeaders(), }); expect(res.status).toBe(200); - // ノートオーナーは通常 note_members 行を持たないため、shared predicate には - // `note_pages -> notes.owner_id` 経路が必要。これで note-native page だけでなく - // linked personal page も listing と `assertPageViewAccess` で整合する。 - // Verify the shared predicate contains the note-owner branch via note_pages, - // so linked personal pages remain visible to note owners too. - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("note_pages"); - expect(serialised).toContain("np.page_id = p.id"); - expect(serialised).toContain("n.owner_id"); + expect(res.headers.get("Deprecation")).toBe("true"); + const body = (await res.json()) as { pages: unknown[] }; + expect(body.pages).toEqual([]); }); it("returns 401 without auth header", async () => { - const app = createPagesApp([{ rows: [] }]); + const app = createPagesApp([]); const res = await app.request("/api/pages", { method: "GET" }); expect(res.status).toBe(401); }); +}); - it("clamps limit/offset to safe ranges", async () => { - const app = createPagesApp([{ rows: [] }]); - - const res = await app.request("/api/pages?limit=999&offset=-5", { - method: "GET", - headers: authHeaders(), - }); - - // 不正な値でも 200 を返し、内部で clamp する。 - // Even with out-of-range params, the endpoint clamps to safe defaults and returns 200. - expect(res.status).toBe(200); - }); - - it("falls back to defaults when limit/offset are non-numeric", async () => { - const app = createPagesApp([{ rows: [] }]); - - // `Number("abc")` だと NaN が SQL に渡って失敗するため、`parseInt + || default` でガードしている。 - // Guards against `NaN` reaching SQL when params can't be parsed as integers. - const res = await app.request("/api/pages?limit=abc&offset=xyz", { - method: "GET", - headers: authHeaders(), - }); - - expect(res.status).toBe(200); - }); +describe("POST /api/pages", () => { + it("calls ensureDefaultNote when note_id omitted and returns 201", async () => { + vi.mocked(ensureDefaultNote).mockClear(); - it("selects p.note_id so callers can distinguish personal vs note-native pages in mixed listings", async () => { - const { app, chains } = createPagesAppWithChains([{ rows: [] }]); + const createdAt = new Date("2026-03-01T12:00:00Z"); + const updatedAt = new Date("2026-03-01T12:00:01Z"); + const app = createPagesApp([ + [ + { + id: "new-page-id", + ownerId: TEST_USER_ID, + noteId: "default-note-mock", + title: null, + contentPreview: null, + sourcePageId: null, + sourceUrl: null, + thumbnailUrl: null, + thumbnailObjectId: null, + createdAt, + updatedAt, + isDeleted: false, + }, + ], + ]); - const res = await app.request("/api/pages?scope=shared", { - method: "GET", + const res = await app.request("/api/pages", { + method: "POST", headers: authHeaders(), + body: JSON.stringify({ title: "Hello" }), }); - expect(res.status).toBe(200); - // `scope=shared` は note-native ページも返すため、`zedi_list_pages` MCP ツールや - // クライアントは行ごとに `note_id` を見て個人 / ノートネイティブを判別する。 - // SELECT に `p.note_id` が残っていることを保証する(PR #727 / #719 リグレッション)。 - // The mixed `scope=shared` listing must surface `note_id` so callers (e.g. the - // `zedi_list_pages` MCP tool) can bucket personal vs note-native rows. - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("p.note_id"); + expect(res.status).toBe(201); + expect(ensureDefaultNote).toHaveBeenCalledTimes(1); + const body = (await res.json()) as { id: string; owner_id: string }; + expect(body.id).toBe("new-page-id"); + expect(body.owner_id).toBe(TEST_USER_ID); }); - it("filters out internal special pages (special_kind, is_schema) by default", async () => { - const { app, chains } = createPagesAppWithChains([{ rows: [] }]); + it("returns 403 when note_id points to a note the caller cannot edit", async () => { + const otherOwnerNote = { + id: "foreign-note-id", + ownerId: "other-user", + title: "Someone else's note", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + isDefault: false, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + }; + + const app = createPagesApp([[otherOwnerNote], [], []]); const res = await app.request("/api/pages", { - method: "GET", + method: "POST", headers: authHeaders(), + body: JSON.stringify({ title: "Nope", note_id: "foreign-note-id" }), }); - expect(res.status).toBe(200); - // execute() に渡された SQL チャンクに special_kind / is_schema の除外句が - // 含まれていることを検証する(Drizzle sql テンプレートの queryChunks を文字列化)。 - // Verify the SQL passed to execute() contains the special-kind exclusion clause. - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("special_kind IS NULL"); - expect(serialised).toContain("is_schema = false"); + expect(res.status).toBe(403); }); - it("includes internal special pages when include_special=true", async () => { - const { app, chains } = createPagesAppWithChains([{ rows: [] }]); + it("returns 404 when note_id does not exist", async () => { + const app = createPagesApp([[]]); - const res = await app.request("/api/pages?include_special=true", { - method: "GET", + const res = await app.request("/api/pages", { + method: "POST", headers: authHeaders(), + body: JSON.stringify({ title: "Nope", note_id: "missing-note-id" }), }); - expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).not.toContain("special_kind IS NULL"); - expect(serialised).not.toContain("is_schema = false"); + expect(res.status).toBe(404); }); }); @@ -274,7 +287,7 @@ describe("PUT /api/pages/:id/content", () => { it("creates page_contents when expected_version is 0 and no row exists (aligns with GET version 0)", async () => { const ydocB64 = Buffer.from("hello").toString("base64"); const { app, chains } = createPagesAppWithChains([ - [{ id: PAGE_ID, ownerId: TEST_USER_ID }], + ...pageAccessPrefix(), [{ version: 1, pageId: PAGE_ID }], [], [{ id: "snap-1" }], @@ -300,7 +313,7 @@ describe("PUT /api/pages/:id/content", () => { it("accepts ydoc_state empty string for first save (matches GET when page_contents is missing)", async () => { const app = createPagesApp([ - [{ id: PAGE_ID, ownerId: TEST_USER_ID }], + ...pageAccessPrefix(), [{ version: 1, pageId: PAGE_ID }], [], [{ id: "snap-2" }], @@ -321,8 +334,8 @@ describe("PUT /api/pages/:id/content", () => { expect(body.version).toBe(1); }); - it("returns 400 when ydoc_state is omitted", async () => { - const app = createPagesApp([[{ id: PAGE_ID, ownerId: TEST_USER_ID }]]); + it("returns 400 when ydoc_state is omitted (before DB access checks)", async () => { + const app = createPagesApp([]); const res = await app.request(`/api/pages/${PAGE_ID}/content`, { method: "PUT", @@ -343,16 +356,11 @@ describe("PUT /api/pages/:id/content", () => { it("issues an extra SELECT for rename detection when body.title is provided", async () => { const ydocB64 = Buffer.from("hello").toString("base64"); const { app, chains } = createPagesAppWithChains([ - // 1. access check select - [{ id: PAGE_ID, ownerId: TEST_USER_ID }], - // 2. UPDATE page_contents (optimistic version path) + ...pageAccessPrefix(), [{ version: 2, pageId: PAGE_ID }], - // 3. SELECT pages.title in applyPagesMetadataUpdate (rename detection) - // Same title as body → no propagation triggered. [{ title: "Same Title" }], - // 4. UPDATE pages (title + updatedAt) [], - // 5. auto-snapshot select (empty → no snapshot) + [], [], ]); @@ -370,14 +378,9 @@ describe("PUT /api/pages/:id/content", () => { const body = (await res.json()) as { version: number }; expect(body.version).toBe(2); - // applyPagesMetadataUpdate must have issued the extra SELECT for the - // pages.title read. The shape includes access-check SELECT + title-read - // SELECT (+ auto-snapshot SELECT), and at least one UPDATE chain. - // リネーム検出のため pages.title を読む SELECT が増えること。 const selectChains = chains.filter((c) => c.startMethod === "select"); expect(selectChains.length).toBeGreaterThanOrEqual(2); const updateChains = chains.filter((c) => c.startMethod === "update"); - // UPDATE page_contents + UPDATE pages expect(updateChains.length).toBeGreaterThanOrEqual(2); }); }); diff --git a/server/api/src/__tests__/routes/search.test.ts b/server/api/src/__tests__/routes/search.test.ts index 5a5656c6..b5b3c0c6 100644 --- a/server/api/src/__tests__/routes/search.test.ts +++ b/server/api/src/__tests__/routes/search.test.ts @@ -2,13 +2,13 @@ * GET /api/search のスコープ分離テスト (Issue #718 Phase 5-1)。 * Tests for scope separation on GET /api/search (Issue #718 Phase 5-1). * - * Phase 1〜4 で `pages.note_id` による個人 / ノートネイティブページのスコープ分離が - * 導入されたため、`scope=own` でも SQL レベルで `p.note_id IS NULL` を強制し、 - * ノートネイティブページがリークしないことを検証する。 + * Issue #823: `scope=own` は呼び出し元のデフォルトノート配下に限定し、`scope=shared` + * は共有ノート(オーナー / メンバー / ドメインルール)へ所属するページを横断する。 + * `note_pages` テーブルは廃止されている。 * - * Ensures `scope=own` restricts results to personal pages (note_id IS NULL) at - * the SQL layer so note-native pages cannot leak into personal search results, - * while `scope=shared` keeps its existing cross-scope behavior. + * Issue #823: `scope=own` restricts to the caller's default note; `scope=shared` spans + * pages in notes reachable via owner / member / domain access. The `note_pages` table + * is gone. */ import { describe, it, expect, vi } from "vitest"; import type { Context, Next } from "hono"; @@ -23,9 +23,26 @@ vi.mock("../../middleware/auth.js", () => ({ }, })); +vi.mock("../../services/defaultNoteService.js", () => ({ + getDefaultNoteOrNull: vi.fn(async () => ({ + id: "default-note-search-mock", + ownerId: "user-search-test-001", + title: "Mock default", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + isDefault: true, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + })), +})); + import { Hono } from "hono"; import searchRoutes from "../../routes/search.js"; import { createMockDb } from "../createMockDb.js"; +import { getDefaultNoteOrNull } from "../../services/defaultNoteService.js"; const TEST_USER_ID = "user-search-test-001"; @@ -70,9 +87,7 @@ describe("GET /api/search", () => { expect(chains).toHaveLength(0); }); - it("scope=own restricts SQL to personal pages (note_id IS NULL) to prevent note-native leakage", async () => { - // Phase 5-1 防御的修正: 個人検索結果にノートネイティブページが混ざってはならない。 - // Phase 5-1 defensive fix: personal search results must never include note-native pages. + it("scope=own binds listing to default note id from getDefaultNoteOrNull (issue #823)", async () => { const { app, chains } = createSearchApp([{ rows: [] }]); const res = await app.request("/api/search?q=hello&scope=own", { @@ -84,13 +99,13 @@ describe("GET /api/search", () => { const executeChain = chains.find((chain) => chain.startMethod === "execute"); expect(executeChain).toBeDefined(); const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("p.note_id IS NULL"); - expect(serialised).toContain("p.owner_id"); + expect(serialised).toContain("default-note-search-mock"); + expect(serialised).toContain("p.note_id"); + expect(serialised).not.toContain("is_default"); + expect(serialised).not.toContain("SELECT n.id FROM notes n"); }); it("defaults to scope=own when scope query parameter is omitted", async () => { - // scope 未指定時の既定は個人スコープ。省略時にノートネイティブがリークしないよう同じガードを要求する。 - // Omitted scope defaults to personal, so the same guard must apply. const { app, chains } = createSearchApp([{ rows: [] }]); const res = await app.request("/api/search?q=hello", { @@ -102,12 +117,26 @@ describe("GET /api/search", () => { const executeChain = chains.find((chain) => chain.startMethod === "execute"); expect(executeChain).toBeDefined(); const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("p.note_id IS NULL"); + expect(serialised).toContain("default-note-search-mock"); + expect(serialised).toContain("p.note_id"); }); - it("scope=shared keeps non-personal access branches alongside the personal ownership guard", async () => { - // shared は個人ページの owner 分岐を残しつつ、note_pages 経由の共有アクセスも併せ持つ。 - // `shared` keeps the personal-owner guard but must still include note-based branches. + it("scope=own returns empty results when default note is missing", async () => { + vi.mocked(getDefaultNoteOrNull).mockResolvedValueOnce(null); + const { app, chains } = createSearchApp([]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: unknown[] }; + expect(body.results).toEqual([]); + expect(chains.find((c) => c.startMethod === "execute")).toBeUndefined(); + }); + + it("scope=shared uses note ownership / member / domain EXISTS branches without note_pages", async () => { const { app, chains } = createSearchApp([{ rows: [] }]); const res = await app.request("/api/search?q=hello&scope=shared", { @@ -119,8 +148,8 @@ describe("GET /api/search", () => { const executeChain = chains.find((chain) => chain.startMethod === "execute"); expect(executeChain).toBeDefined(); const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("p.note_id IS NULL"); - expect(serialised).toContain("note_pages"); + expect(serialised).not.toContain("note_pages"); + expect(serialised).toContain("note_members"); expect(serialised).toContain("OR EXISTS"); }); @@ -135,7 +164,7 @@ describe("GET /api/search", () => { expect(res.status).toBe(200); }); - it("scope=shared keeps personal ownership limited to personal pages and adds note-owner access via note_pages", async () => { + it("scope=shared keeps note-scoped EXISTS predicates (no note_pages join)", async () => { const { app, chains } = createSearchApp([{ rows: [] }]); const res = await app.request("/api/search?q=hello&scope=shared", { @@ -147,25 +176,22 @@ describe("GET /api/search", () => { const executeChain = chains.find((chain) => chain.startMethod === "execute"); expect(executeChain).toBeDefined(); const serialised = JSON.stringify(executeChain?.startArgs); - expect(serialised).toContain("p.owner_id"); - expect(serialised).toContain("p.note_id IS NULL"); - expect(serialised).toContain("note_pages"); - expect(serialised).toContain("np.page_id = p.id"); - expect(serialised).toContain("n.owner_id"); + expect(serialised).toContain(TEST_USER_ID); + expect(serialised).not.toContain("note_pages"); + expect(serialised).toContain("p.note_id"); }); - it("response rows include note_id so callers can distinguish personal vs note-native", async () => { - // 呼び出し側 (フロント / MCP) がスコープ判定できるよう、レスポンスには必ず note_id を含める。 - // Callers (frontend / MCP) must be able to tell personal vs note-native, so include note_id. + it("response rows include note_id so callers can distinguish pages by owning note", async () => { + const defaultNotePageId = "11111111-1111-1111-1111-111111111111"; const { app, chains } = createSearchApp([ { rows: [ { - id: "page-personal", - title: "Personal", + id: defaultNotePageId, + title: "In default note", content_preview: null, updated_at: new Date("2026-04-01T00:00:00Z").toISOString(), - note_id: null, + note_id: defaultNotePageId, }, ], }, @@ -181,27 +207,22 @@ describe("GET /api/search", () => { expect(body.results).toHaveLength(1); expect(body.results[0]).toHaveProperty("note_id"); - // SELECT 句に p.note_id が含まれていることも検証する (両スコープ)。 - // Verify SELECT list includes p.note_id regardless of scope. const executeChain = chains.find((chain) => chain.startMethod === "execute"); const serialised = JSON.stringify(executeChain?.startArgs); expect(serialised).toContain("p.note_id"); }); - it("scope=own SELECT list exposes p.note_id and response surfaces note_id: null", async () => { - // SQL の SELECT に note_id が含まれること、かつ JSON ペイロード上も - // note_id (個人ページなので null) が露出することを併せて契約する。 - // Pin both the SQL projection and the JSON payload contract: scope=own - // surfaces note_id (null for personal pages) on each result row. + it("scope=own SELECT list exposes p.note_id (default-note constraint)", async () => { + const defaultNotePageId = "22222222-2222-2222-2222-222222222222"; const { app, chains } = createSearchApp([ { rows: [ { - id: "page-own", - title: "Own page", + id: defaultNotePageId, + title: "Own scope page", content_preview: null, updated_at: new Date("2026-04-01T00:00:00Z").toISOString(), - note_id: null, + note_id: defaultNotePageId, }, ], }, @@ -215,10 +236,12 @@ describe("GET /api/search", () => { expect(res.status).toBe(200); const body = (await res.json()) as { results: Array> }; expect(body.results).toHaveLength(1); - expect(body.results[0]).toHaveProperty("note_id", null); + expect(body.results[0]).toHaveProperty("note_id", defaultNotePageId); const executeChain = chains.find((chain) => chain.startMethod === "execute"); const serialised = JSON.stringify(executeChain?.startArgs); expect(serialised).toContain("p.note_id"); + expect(serialised).toContain("default-note-search-mock"); + expect(serialised).not.toContain("is_default"); }); }); diff --git a/server/api/src/__tests__/routes/syncPages.test.ts b/server/api/src/__tests__/routes/syncPages.test.ts index 86660741..f06163df 100644 --- a/server/api/src/__tests__/routes/syncPages.test.ts +++ b/server/api/src/__tests__/routes/syncPages.test.ts @@ -17,6 +17,23 @@ vi.mock("../../middleware/auth.js", () => ({ }, })); +/** PR 1b: GET/POST sync は ensureDefaultNote を先に叩く。モックしてチェーンをページ同期クエリに寄せる。 */ +vi.mock("../../services/defaultNoteService.js", () => ({ + ensureDefaultNote: vi.fn(async () => ({ + id: "sync-default-note-id", + ownerId: "user-owner", + title: "Default", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + isDefault: true, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + })), +})); + import { Hono } from "hono"; import syncPagesRoute from "../../routes/syncPages.js"; import { createMockDb } from "../createMockDb.js"; @@ -108,7 +125,14 @@ describe("POST /api/sync/pages — IDOR protection", () => { const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ // 1: bulk page fetch (LWW pre-load) - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + [ + { + id: OWNED_PAGE, + ownerId: TEST_USER_ID, + noteId: "sync-default-note-id", + updatedAt: oldDate, + }, + ], // 2: page update undefined, // 3: owned pages query for links @@ -143,7 +167,14 @@ describe("POST /api/sync/pages — IDOR protection", () => { const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ // 1: bulk page fetch (LWW pre-load) - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + [ + { + id: OWNED_PAGE, + ownerId: TEST_USER_ID, + noteId: "sync-default-note-id", + updatedAt: oldDate, + }, + ], // 2: page update undefined, // 3: owned pages query for ghost_links @@ -257,7 +288,14 @@ describe("POST /api/sync/pages — IDOR protection", () => { it("defaults link_type to 'wiki' when omitted in body.links (legacy client compat)", async () => { const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + [ + { + id: OWNED_PAGE, + ownerId: TEST_USER_ID, + noteId: "sync-default-note-id", + updatedAt: oldDate, + }, + ], undefined, [{ id: OWNED_PAGE }], undefined, @@ -291,7 +329,14 @@ describe("POST /api/sync/pages — IDOR protection", () => { // `(source_id, link_type)` pair and one INSERT per row. const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + [ + { + id: OWNED_PAGE, + ownerId: TEST_USER_ID, + noteId: "sync-default-note-id", + updatedAt: oldDate, + }, + ], undefined, [{ id: OWNED_PAGE }], undefined, // DELETE wiki @@ -341,7 +386,14 @@ describe("POST /api/sync/pages — IDOR protection", () => { // existing tag edges untouched. const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + [ + { + id: OWNED_PAGE, + ownerId: TEST_USER_ID, + noteId: "sync-default-note-id", + updatedAt: oldDate, + }, + ], undefined, [{ id: OWNED_PAGE }], undefined, // DELETE wiki only @@ -366,7 +418,14 @@ describe("POST /api/sync/pages — IDOR protection", () => { it("accepts link_type='tag' on ghost_links and defaults to 'wiki' when omitted", async () => { const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + [ + { + id: OWNED_PAGE, + ownerId: TEST_USER_ID, + noteId: "sync-default-note-id", + updatedAt: oldDate, + }, + ], undefined, [{ id: OWNED_PAGE }], undefined, // DELETE ghost wiki @@ -400,11 +459,7 @@ describe("POST /api/sync/pages — IDOR protection", () => { }); it("rejects unknown link_type values with 400", async () => { - const oldDate = new Date("2024-01-01T00:00:00Z"); - const { app } = createSyncApp([ - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], - undefined, - ]); + const { app } = createSyncApp([]); const res = await app.request("/api/sync/pages", { method: "POST", @@ -423,7 +478,14 @@ describe("POST /api/sync/pages — IDOR protection", () => { const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ // 1: bulk page fetch (LWW pre-load) - [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + [ + { + id: OWNED_PAGE, + ownerId: TEST_USER_ID, + noteId: "sync-default-note-id", + updatedAt: oldDate, + }, + ], // 2: page update undefined, // 3: owned pages query for links diff --git a/server/api/src/__tests__/services/pageAccessService.test.ts b/server/api/src/__tests__/services/pageAccessService.test.ts index 0988e6ad..7f8f8360 100644 --- a/server/api/src/__tests__/services/pageAccessService.test.ts +++ b/server/api/src/__tests__/services/pageAccessService.test.ts @@ -1,11 +1,12 @@ /** * `services/pageAccessService.ts` のテスト。 * - * Issue #713 で導入した「個人ページ vs ノートネイティブページ」の権限分岐を - * 中心に検証する。 + * Issue #823 以降はすべてのページが `pages.note_id` でノートに所属し、閲覧・編集は + * `getNoteRole` / `canEdit` のみで判定する(`pages.owner_id` の一致だけでは許可しない)。 * - * Tests for `services/pageAccessService.ts`. Focused on the personal-page vs. - * note-native-page authorization split introduced in issue #713. + * Tests for `services/pageAccessService.ts`. After issue #823 every page belongs to a + * note via `pages.note_id`; view/edit authorization uses `getNoteRole` / `canEdit` + * only — owning the `pages` row alone is never sufficient. */ import { describe, it, expect } from "vitest"; import type { Database } from "../../types/index.js"; @@ -22,130 +23,110 @@ function asDb(db: unknown): Database { return db as unknown as Database; } -describe("assertPageViewAccess (issue #713)", () => { - it("allows personal page owner", async () => { +function noteRow( + ownerId: string, + overrides: Partial<{ editPermission: "owner_only" | "members_editors" }> = {}, +) { + return { + id: NOTE_ID, + ownerId, + title: "n", + visibility: "private" as const, + editPermission: overrides.editPermission ?? "owner_only", + isOfficial: false, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + }; +} + +/** Private note, caller not owner: member + domain rule lookups run then deny. */ +function noRoleChains(pageOwnerId: string) { + return [ + [{ id: PAGE_ID, ownerId: pageOwnerId, noteId: NOTE_ID }], + [{ email: USER_EMAIL }], + [noteRow(OTHER_USER_ID)], + [], + [], + ]; +} + +describe("assertPageViewAccess (issue #823)", () => { + it("allows note owner even when pages.owner_id differs", async () => { const { db } = createMockDb([ - [{ id: PAGE_ID, ownerId: USER_ID, noteId: null }], // getPageOwnership + [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: NOTE_ID }], + [{ email: USER_EMAIL }], + [noteRow(USER_ID)], ]); await expect(assertPageViewAccess(asDb(db), PAGE_ID, USER_ID)).resolves.toBeUndefined(); }); - it("denies non-owner / non-member on personal page", async () => { - const { db } = createMockDb([ - [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: null }], // getPageOwnership - [{ email: USER_EMAIL }], // getUserEmailLowercase - [], // notePages JOIN — no membership - [], // note_pages -> notes.owner_id fallback - ]); + it("denies when caller has no resolved note role (pages.owner_id match is insufficient)", async () => { + const { db } = createMockDb(noRoleChains(USER_ID)); await expect(assertPageViewAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ status: 403, }); }); - it("allows note owner on a linked personal page even without a note_members row", async () => { + it("denies when active note row is missing", async () => { const { db } = createMockDb([ - [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: null }], // getPageOwnership - [{ email: USER_EMAIL }], // getUserEmailLowercase - [], // notePages JOIN — no membership - [{ noteId: NOTE_ID }], // note_pages -> notes.owner_id fallback + [{ id: PAGE_ID, ownerId: USER_ID, noteId: NOTE_ID }], + [{ email: USER_EMAIL }], + [], ]); - await expect(assertPageViewAccess(asDb(db), PAGE_ID, USER_ID)).resolves.toBeUndefined(); + await expect(assertPageViewAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ + status: 403, + }); }); it("denies note-native page when caller has no role on the note", async () => { - // ノートネイティブページは pages.ownerId 一致では許可しない(脱退者対策)。 - // Note-native: owning the underlying pages row is intentionally not enough. - const { db } = createMockDb([ - [{ id: PAGE_ID, ownerId: USER_ID, noteId: NOTE_ID }], // getPageOwnership - [{ email: USER_EMAIL }], // getUserEmailLowercase - [], // getNoteRole → findActiveNoteById: note not found by helper path - ]); + const { db } = createMockDb(noRoleChains(USER_ID)); await expect(assertPageViewAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ status: 403, }); }); it("allows note owner on note-native page", async () => { - const noteRow = { - id: NOTE_ID, - ownerId: USER_ID, - title: "n", - visibility: "private", - editPermission: "owner_only", - isOfficial: false, - viewCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - isDeleted: false, - }; const { db } = createMockDb([ - [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: NOTE_ID }], // getPageOwnership - [{ email: USER_EMAIL }], // getUserEmailLowercase - [noteRow], // getNoteRole → findActiveNoteById (owner short-circuits, no further queries) + [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: NOTE_ID }], + [{ email: USER_EMAIL }], + [noteRow(USER_ID)], ]); await expect(assertPageViewAccess(asDb(db), PAGE_ID, USER_ID)).resolves.toBeUndefined(); }); it("returns 404 when page is missing", async () => { - const { db } = createMockDb([[]]); // getPageOwnership empty + const { db } = createMockDb([[]]); await expect(assertPageViewAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ status: 404, }); }); }); -describe("assertPageEditAccess (issue #713)", () => { - it("allows personal page owner", async () => { - const { db } = createMockDb([[{ id: PAGE_ID, ownerId: USER_ID, noteId: null }]]); - await expect(assertPageEditAccess(asDb(db), PAGE_ID, USER_ID)).resolves.toBeUndefined(); - }); - - it("denies non-owner on personal page (note membership doesn't grant edit)", async () => { - const { db } = createMockDb([[{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: null }]]); - await expect(assertPageEditAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ - status: 403, - }); - }); - +describe("assertPageEditAccess (issue #823)", () => { it("allows note owner on note-native page (canEdit=true)", async () => { - const noteRow = { - id: NOTE_ID, - ownerId: USER_ID, - title: "n", - visibility: "private", - editPermission: "owner_only", - isOfficial: false, - viewCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - isDeleted: false, - }; const { db } = createMockDb([ [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: NOTE_ID }], [{ email: USER_EMAIL }], - [noteRow], // getNoteRole owner short-circuit + [noteRow(USER_ID)], ]); await expect(assertPageEditAccess(asDb(db), PAGE_ID, USER_ID)).resolves.toBeUndefined(); }); + it("denies when pages.owner_id matches but caller has no note edit role", async () => { + const { db } = createMockDb(noRoleChains(USER_ID)); + await expect(assertPageEditAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ + status: 403, + }); + }); + it("denies viewer member on note-native page when editPermission=members_editors", async () => { - const noteRow = { - id: NOTE_ID, - ownerId: OTHER_USER_ID, - title: "n", - visibility: "private", - editPermission: "members_editors", - isOfficial: false, - viewCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - isDeleted: false, - }; const { db } = createMockDb([ - [{ id: PAGE_ID, ownerId: USER_ID, noteId: NOTE_ID }], // getPageOwnership (own underlying row) + [{ id: PAGE_ID, ownerId: USER_ID, noteId: NOTE_ID }], [{ email: USER_EMAIL }], - [noteRow], // findActiveNoteById - [{ role: "viewer" }], // member lookup → viewer + [noteRow(OTHER_USER_ID, { editPermission: "members_editors" })], + [{ role: "viewer" }], ]); await expect(assertPageEditAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ status: 403, @@ -153,51 +134,17 @@ describe("assertPageEditAccess (issue #713)", () => { }); it("allows editor member on note-native page when editPermission=members_editors", async () => { - const noteRow = { - id: NOTE_ID, - ownerId: OTHER_USER_ID, - title: "n", - visibility: "private", - editPermission: "members_editors", - isOfficial: false, - viewCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - isDeleted: false, - }; const { db } = createMockDb([ [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: NOTE_ID }], [{ email: USER_EMAIL }], - [noteRow], - [{ role: "editor" }], // editor passes canEdit when editPermission != owner_only + [noteRow(OTHER_USER_ID, { editPermission: "members_editors" })], + [{ role: "editor" }], ]); await expect(assertPageEditAccess(asDb(db), PAGE_ID, USER_ID)).resolves.toBeUndefined(); }); it("denies non-owner of underlying row when no note role resolves", async () => { - // ノートネイティブページの編集権限は note ロールのみで判定。 - // Note-native edit permission depends on note role only — owning the - // underlying pages row (e.g. created the page then was removed) is NOT - // enough. See issue #713. - const noteRow = { - id: NOTE_ID, - ownerId: OTHER_USER_ID, - title: "n", - visibility: "private", - editPermission: "owner_only", - isOfficial: false, - viewCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - isDeleted: false, - }; - const { db } = createMockDb([ - [{ id: PAGE_ID, ownerId: USER_ID, noteId: NOTE_ID }], - [{ email: USER_EMAIL }], - [noteRow], - [], // member lookup empty - [], // domain access lookup empty - ]); + const { db } = createMockDb(noRoleChains(USER_ID)); await expect(assertPageEditAccess(asDb(db), PAGE_ID, USER_ID)).rejects.toMatchObject({ status: 403, }); diff --git a/server/api/src/lib/clipAndCreate.ts b/server/api/src/lib/clipAndCreate.ts index 3dbbb01a..b976cea9 100644 --- a/server/api/src/lib/clipAndCreate.ts +++ b/server/api/src/lib/clipAndCreate.ts @@ -15,6 +15,7 @@ import { pages, pageContents } from "../schema/index.js"; import type * as schema from "../schema/index.js"; import { buildArticleSchema, extractArticleFromUrl } from "./articleExtractor.js"; import type { AIProviderType, TokenUsage } from "../types/index.js"; +import { ensureDefaultNote } from "../services/defaultNoteService.js"; const YDOC_FRAGMENT = "default"; @@ -84,10 +85,12 @@ export async function clipAndCreate(input: ClipAndCreateInput): Promise { + const defaultNote = await ensureDefaultNote(tx, userId); const [page] = await tx .insert(pages) .values({ ownerId: userId, + noteId: defaultNote.id, title: article.title, contentPreview: article.contentText || null, sourceUrl: article.finalUrl, diff --git a/server/api/src/lib/welcomePageService.ts b/server/api/src/lib/welcomePageService.ts index 6bfb81de..47a3f3d8 100644 --- a/server/api/src/lib/welcomePageService.ts +++ b/server/api/src/lib/welcomePageService.ts @@ -18,6 +18,8 @@ import Link from "@tiptap/extension-link"; import { and, eq, isNull, isNotNull, sql } from "drizzle-orm"; import { pages, pageContents, userOnboardingStatus } from "../schema/index.js"; import type { Database } from "../types/index.js"; +import type { DbOrTx } from "../types/dbOrTx.js"; +import { ensureDefaultNote } from "../services/defaultNoteService.js"; import { VideoServer } from "./videoServerExtension.js"; import { welcomePageContent, @@ -62,15 +64,6 @@ const welcomePageExtensions = [ */ const welcomePageSchema = getSchema(welcomePageExtensions); -/** - * Drizzle のトランザクション型。サービスを route 側のトランザクションに参加 - * させるため型を抽出する。 - * - * Drizzle transaction type, extracted so this service can join a route-level - * transaction instead of opening its own. - */ -export type DbOrTx = Parameters[0]>[0] | Database; - /** * ウェルカムページ生成結果。Welcome page generation result. */ @@ -167,25 +160,15 @@ export async function insertWelcomePage( const ydoc = prosemirrorJSONToYDoc(welcomePageSchema, doc, YDOC_FRAGMENT); const ydocState = Y.encodeStateAsUpdate(ydoc); - // PR 1a ではウェルカムページの所属モデルは旧来どおり「個人ページ」 - // (`note_id = NULL`) のままにしておく。理由は以下: - // - `GET /api/notes/:id/pages` と `routes/notes/search.ts` は `note_pages` - // 経由でページを引くため、`note_id` だけ埋めて `note_pages` 行を作らない - // と、デフォルトノート画面でウェルカムページが見えない (PR コメント参照) - // - 旧 `/home` (`scope=own`, `note_id IS NULL`) でも見えなくなる - // PR 1b で個人ページをまとめてデフォルトノートへ移行する際にウェルカム - // ページも一緒に移行する。それまでは挙動互換を保つ。 - // - // PR 1a keeps the welcome page as a "personal page" (`note_id = NULL`). - // Setting `note_id` here without also creating a `note_pages` row would hide - // the welcome page from `GET /api/notes/:id/pages` and note search (both - // read via `note_pages`), and from the legacy `/home` listing - // (`scope=own` / `note_id IS NULL`). PR 1b will migrate personal pages — - // including welcome pages — into the default note in one coordinated step. + // Issue #823: ウェルカムページは呼び出し元ユーザーのデフォルトノートに所属させる。 + // Welcome pages belong to the user's default note (issue #823). + const defaultNote = await ensureDefaultNote(tx, userId); + const inserted = await tx .insert(pages) .values({ ownerId: userId, + noteId: defaultNote.id, title, contentPreview, kind: "welcome", diff --git a/server/api/src/routes/notes/crud.ts b/server/api/src/routes/notes/crud.ts index bb9d3795..0ea215e4 100644 --- a/server/api/src/routes/notes/crud.ts +++ b/server/api/src/routes/notes/crud.ts @@ -10,8 +10,8 @@ */ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; -import { eq, ne, and, or, desc, asc, sql, inArray } from "drizzle-orm"; -import { notes, notePages, noteMembers, pages, users } from "../../schema/index.js"; +import { eq, ne, and, or, desc, sql, inArray } from "drizzle-orm"; +import { notes, noteMembers, pages, users } from "../../schema/index.js"; import type { Note } from "../../schema/index.js"; import { authRequired, authOptional } from "../../middleware/auth.js"; import type { AppEnv } from "../../types/index.js"; @@ -323,16 +323,10 @@ app.get("/:noteId", authOptional, async (c) => { createdAt: pages.createdAt, updatedAt: pages.updatedAt, isDeleted: pages.isDeleted, - sortOrder: notePages.sortOrder, - addedByUserId: notePages.addedByUserId, - addedAt: notePages.createdAt, }) - .from(notePages) - .innerJoin(pages, eq(notePages.pageId, pages.id)) - .where( - and(eq(notePages.noteId, noteId), eq(notePages.isDeleted, false), eq(pages.isDeleted, false)), - ) - .orderBy(asc(notePages.sortOrder)); + .from(pages) + .where(and(eq(pages.noteId, noteId), eq(pages.isDeleted, false))) + .orderBy(desc(pages.updatedAt)); const response: NoteDetailApiResponse = { ...noteRowToApi(note), @@ -341,12 +335,7 @@ app.get("/:noteId", authOptional, async (c) => { (p): NotePageApiItem => ({ id: p.id, owner_id: p.ownerId, - // `note_id` は「リンクされた個人ページ」か「ノートネイティブページ」かを - // 区別する。note-native だけに有効なアクション(例: 「個人に取り込み」) - // をクライアント側で出し分けるため、Phase 3 から明示的に返す。 - // Surface `note_id` so clients can distinguish linked personal pages - // (null) from note-native pages (non-null). Phase 3 needs this to gate - // note-native-only actions such as "copy to personal". + /** 所属ノート ID(Issue #823 以降は常にこのノート ID)。 */ note_id: p.noteId, source_page_id: p.sourcePageId, title: p.title, @@ -356,9 +345,6 @@ app.get("/:noteId", authOptional, async (c) => { created_at: p.createdAt, updated_at: p.updatedAt, is_deleted: p.isDeleted, - sort_order: p.sortOrder, - added_by_user_id: p.addedByUserId, - added_at: p.addedAt, }), ), }; diff --git a/server/api/src/routes/notes/helpers.ts b/server/api/src/routes/notes/helpers.ts index 05635206..e6299bd2 100644 --- a/server/api/src/routes/notes/helpers.ts +++ b/server/api/src/routes/notes/helpers.ts @@ -4,14 +4,7 @@ */ import { HTTPException } from "hono/http-exception"; import { eq, and, sql, inArray } from "drizzle-orm"; -import { - notes, - notePages, - noteMembers, - noteDomainAccess, - pages, - users, -} from "../../schema/index.js"; +import { notes, noteMembers, noteDomainAccess, pages, users } from "../../schema/index.js"; import type { Note } from "../../schema/index.js"; import type { Database } from "../../types/index.js"; import type { NoteApiFields, NoteRole, NoteMemberRole } from "./types.js"; @@ -105,19 +98,12 @@ export async function getActivePageCounts( if (noteIds.length === 0) return new Map(); const counts = await db .select({ - noteId: notePages.noteId, + noteId: pages.noteId, count: sql`cast(count(*) as integer)`, }) - .from(notePages) - .innerJoin(pages, eq(notePages.pageId, pages.id)) - .where( - and( - inArray(notePages.noteId, noteIds), - eq(notePages.isDeleted, false), - eq(pages.isDeleted, false), - ), - ) - .groupBy(notePages.noteId); + .from(pages) + .where(and(inArray(pages.noteId, noteIds), eq(pages.isDeleted, false))) + .groupBy(pages.noteId); return new Map(counts.map((c) => [c.noteId, c.count])); } diff --git a/server/api/src/routes/notes/pages.ts b/server/api/src/routes/notes/pages.ts index f6f44ee7..2a639004 100644 --- a/server/api/src/routes/notes/pages.ts +++ b/server/api/src/routes/notes/pages.ts @@ -1,136 +1,23 @@ /** * ノートページ管理ルート * - * POST /:noteId/pages — ページ追加(リンク or タイトル新規) - * POST /:noteId/pages/copy-from-personal/:pageId — 個人ページをノートにコピー - * POST /:noteId/pages/:pageId/copy-to-personal — ノートページを個人にコピー - * DELETE /:noteId/pages/:pageId — ページ削除 - * PUT /:noteId/pages — ページ並び替え - * GET /:noteId/pages — ノートのページ一覧 + * POST /:noteId/pages — ノート配下にページ新規作成(タイトル) + * DELETE /:noteId/pages/:pageId — ページ削除(所属ノート一致時) + * PUT /:noteId/pages — 並び替え noop(Issue #823、`updated_at` 順を使用) + * GET /:noteId/pages — ノートのページ一覧(`pages.note_id` フィルタ) + * + * Issue #823 で `copy-from-personal` / `copy-to-personal` と `page_id` リンク経路は削除。 */ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; -import { eq, and, asc, sql } from "drizzle-orm"; -import { notes, notePages, pages, pageContents } from "../../schema/index.js"; +import { eq, and, desc } from "drizzle-orm"; +import { notes, pages } from "../../schema/index.js"; import { authRequired } from "../../middleware/auth.js"; -import type { AppEnv, Database } from "../../types/index.js"; +import type { AppEnv } from "../../types/index.js"; import { getNoteRole, canEdit } from "./helpers.js"; const app = new Hono(); -/** - * コピー時に引き継ぐページメタデータのサブセット。 - * Source-page metadata subset that copy endpoints duplicate into the new row. - */ -interface CopyablePageMetadata { - title: string | null; - contentPreview: string | null; - thumbnailUrl: string | null; - sourceUrl: string | null; -} - -/** - * コピーで作られた新ページ行をレスポンスに載せるときの形。クライアントは - * これを IndexedDB に書き戻して `/home` に即反映する(`copy-to-personal`)。 - * Shape of a copied page row in copy endpoint responses. Clients write it - * through to IndexedDB so the new page surfaces on `/home` without waiting - * for the next sync. - */ -interface CopiedPageApiItem { - id: string; - owner_id: string; - note_id: string | null; - source_page_id: string | null; - title: string | null; - content_preview: string | null; - thumbnail_url: string | null; - source_url: string | null; - created_at: string; - updated_at: string; - is_deleted: boolean; -} - -/** - * ページ行と `page_contents` を新しい `pages` 行へコピーする共通ヘルパー。 - * - * `copy-from-personal`(個人 → ノート)と `copy-to-personal`(ノート → 個人)の - * 両エンドポイントで共有する。呼び出し側でスコープ判定とソース取得を済ませ、 - * ここでは「新しい行を作る」部分だけに責務を絞る。`page_contents` がない - * (= 初回保存前の)ソースはスキップし、コピー先の初回保存時に通常ルートで - * 作成させる。新しい行は `RETURNING *` で取り出してレスポンスに載せられる - * 形で返すので、クライアントはサーバー再問い合わせなしにローカルストレージへ - * 書き戻せる。 - * - * Shared helper that clones the page row + `page_contents` into a brand new - * `pages` row. Shared between `copy-from-personal` and `copy-to-personal` so - * the two endpoints stop duplicating this block. The caller handles - * authorization / source fetching; this helper only performs the insert. If - * the source has no `page_contents` row (never saved), that step is skipped - * and the destination creates its own row on first save via the usual PUT - * content path. The helper returns the full new row so endpoints can include - * it in the response and clients can write through to local storage without - * a follow-up round trip. See issue #713 Phase 3. - */ -async function copyPageRowWithContent( - tx: Database, - params: { - ownerId: string; - /** `null` で個人ページ、UUID でそのノートのノートネイティブページ */ - destinationNoteId: string | null; - sourcePageId: string; - sourceMetadata: CopyablePageMetadata; - }, -): Promise<{ pageId: string; page: CopiedPageApiItem }> { - const inserted = await tx - .insert(pages) - .values({ - ownerId: params.ownerId, - noteId: params.destinationNoteId, - sourcePageId: params.sourcePageId, - title: params.sourceMetadata.title ?? null, - contentPreview: params.sourceMetadata.contentPreview ?? null, - thumbnailUrl: params.sourceMetadata.thumbnailUrl ?? null, - sourceUrl: params.sourceMetadata.sourceUrl ?? null, - }) - .returning(); - - const newPage = inserted[0]; - if (!newPage) throw new HTTPException(500, { message: "Failed to create page" }); - const newPageId = newPage.id; - - const sourceContent = await tx - .select({ ydocState: pageContents.ydocState, contentText: pageContents.contentText }) - .from(pageContents) - .where(eq(pageContents.pageId, params.sourcePageId)) - .limit(1); - - const contentRow = sourceContent[0]; - if (contentRow) { - await tx.insert(pageContents).values({ - pageId: newPageId, - ydocState: contentRow.ydocState, - version: 1, - contentText: contentRow.contentText ?? null, - }); - } - - const pageApi: CopiedPageApiItem = { - id: newPage.id, - owner_id: newPage.ownerId, - note_id: newPage.noteId, - source_page_id: newPage.sourcePageId, - title: newPage.title, - content_preview: newPage.contentPreview, - thumbnail_url: newPage.thumbnailUrl, - source_url: newPage.sourceUrl, - created_at: newPage.createdAt.toISOString(), - updated_at: newPage.updatedAt.toISOString(), - is_deleted: newPage.isDeleted, - }; - - return { pageId: newPageId, page: pageApi }; -} - // ── POST /:noteId/pages ───────────────────────────────────────────────────── app.post("/:noteId/pages", authRequired, async (c) => { const noteId = c.req.param("noteId"); @@ -152,282 +39,46 @@ app.post("/:noteId/pages", authRequired, async (c) => { }>(); const rawPageId = body.page_id ?? body.pageId; - const pageId = + const hasPageId = typeof rawPageId === "string" && rawPageId.trim() !== "" ? rawPageId.trim() : undefined; + if (hasPageId) { + throw new HTTPException(400, { + message: "page_id linking is removed (issue #823). Create a page with title only.", + }); + } + const title = typeof body.title === "string" && body.title.trim() !== "" ? body.title.trim() : undefined; - if (!pageId && body.title !== undefined && title === undefined) { + if (body.title !== undefined && title === undefined) { throw new HTTPException(400, { message: "title must be a non-empty string" }); } - if (!pageId && !title) { - throw new HTTPException(400, { message: "page_id or title is required" }); - } - - let targetPageId: string; - let sortOrder: number; - - if (pageId) { - const result = await db.transaction(async (tx) => { - const page = await tx - .select({ id: pages.id, ownerId: pages.ownerId, noteId: pages.noteId }) - .from(pages) - .where(and(eq(pages.id, pageId), eq(pages.isDeleted, false))) - .limit(1); - - const firstPage = page[0]; - if (!firstPage) throw new HTTPException(404, { message: "Page not found" }); - if (firstPage.ownerId !== userId) throw new HTTPException(403, { message: "Forbidden" }); - // 既にノートネイティブのページ(別ノートに所属)を `page_id` 経由で別ノートに - // リンクできてしまうと、`/api/pages/:id/content` の認可は元ノート側のロールで - // 解決されるため、リンク先メンバーから見ると「リストには出るが開けない」 - // 壊れたカードになる。Phase 1 では個人ページ(`note_id IS NULL`)のみリンク可。 - // ノート間の取り込みは Phase 3 で導入予定の copy エンドポイントで扱う。 - // - // Reject note-native pages in the `page_id` linking path. If we let a page - // already scoped to note A be linked into note B, then `/api/pages/:id/content` - // still authorizes via the original `pages.note_id` → note B members would see - // a tile they cannot open (403). In Phase 1 only personal pages - // (`note_id IS NULL`) are linkable; cross-note adoption arrives with the - // Phase 3 copy endpoint. See issue #713. - if (firstPage.noteId !== null) { - throw new HTTPException(400, { - message: "Only personal pages can be linked via page_id", - }); - } - const resolvedPageId = firstPage.id; - - const maxOrder = await tx - .select({ max: sql`COALESCE(MAX(${notePages.sortOrder}), 0)` }) - .from(notePages) - .where(and(eq(notePages.noteId, noteId), eq(notePages.isDeleted, false))); - - const order = body.sort_order ?? (maxOrder[0]?.max ?? 0) + 1; - - await tx - .insert(notePages) - .values({ - noteId, - pageId: resolvedPageId, - addedByUserId: userId, - sortOrder: order, - }) - .onConflictDoUpdate({ - target: [notePages.noteId, notePages.pageId], - set: { - isDeleted: false, - sortOrder: order, - updatedAt: new Date(), - }, - }); - - await tx.update(notes).set({ updatedAt: new Date() }).where(eq(notes.id, noteId)); - return { sortOrder: order }; - }); - sortOrder = result.sortOrder; - } else { - const result = await db.transaction(async (tx) => { - // 「タイトルだけで新規作成」経路はノートネイティブページを直接作る。 - // `note_id` を埋めることで個人ホーム (note_id IS NULL フィルタ) には現れず、 - // ノート削除時に ON DELETE CASCADE で一緒に消える。Issue #713 を参照。 - // - // The "create from title" path generates a note-native page directly. - // Setting `note_id` keeps it out of the personal-home listing - // (`note_id IS NULL` filter) and lets ON DELETE CASCADE remove it - // alongside the note. See issue #713. - const created = await tx - .insert(pages) - .values({ - ownerId: userId, - noteId, - title: title ?? null, - }) - .returning(); - - const newPage = created[0]; - if (!newPage) throw new HTTPException(500, { message: "Failed to create page" }); - const newPageId = newPage.id; - - const maxOrder = await tx - .select({ max: sql`COALESCE(MAX(${notePages.sortOrder}), 0)` }) - .from(notePages) - .where(and(eq(notePages.noteId, noteId), eq(notePages.isDeleted, false))); - - const order = body.sort_order ?? (maxOrder[0]?.max ?? 0) + 1; - - await tx - .insert(notePages) - .values({ - noteId, - pageId: newPageId, - addedByUserId: userId, - sortOrder: order, - }) - .onConflictDoUpdate({ - target: [notePages.noteId, notePages.pageId], - set: { - isDeleted: false, - sortOrder: order, - updatedAt: new Date(), - }, - }); - - await tx.update(notes).set({ updatedAt: new Date() }).where(eq(notes.id, noteId)); - return { sortOrder: order }; - }); - sortOrder = result.sortOrder; - } - - return c.json({ added: true, sort_order: sortOrder }); -}); - -// ── POST /:noteId/pages/copy-from-personal/:pageId ────────────────────────── -// 個人ページ(`pages.note_id IS NULL`)をコピーし、指定ノート配下のノート -// ネイティブページ(`note_id = :noteId`, `source_page_id = :pageId`)を作る。 -// 元ページは個人 /home に残り、新しいコピーだけがノートに出る。Issue #713 Phase 3。 -// -// Copy a personal page (`pages.note_id IS NULL`) into the note as a fresh -// note-native page (`note_id = :noteId`, `source_page_id = :pageId`). The -// original stays on the caller's /home; only the copy lives inside the note. -// See issue #713 Phase 3. -app.post("/:noteId/pages/copy-from-personal/:pageId", authRequired, async (c) => { - const noteId = c.req.param("noteId"); - const sourcePageId = c.req.param("pageId"); - const userId = c.get("userId"); - const userEmail = c.get("userEmail"); - const db = c.get("db"); - - const { role, note } = await getNoteRole(noteId, userId, userEmail, db); - if (!note) throw new HTTPException(404, { message: "Note not found" }); - if (!role || !canEdit(role, note)) { - throw new HTTPException(403, { message: "Forbidden" }); + if (!title) { + throw new HTTPException(400, { message: "title is required" }); } - const result = await db.transaction(async (tx) => { - const sourceRows = await tx - .select({ - id: pages.id, - ownerId: pages.ownerId, - noteId: pages.noteId, - title: pages.title, - contentPreview: pages.contentPreview, - thumbnailUrl: pages.thumbnailUrl, - sourceUrl: pages.sourceUrl, + const created = await db.transaction(async (tx) => { + const inserted = await tx + .insert(pages) + .values({ + ownerId: userId, + noteId, + title, }) - .from(pages) - .where(and(eq(pages.id, sourcePageId), eq(pages.isDeleted, false))) - .limit(1); - - const source = sourceRows[0]; - if (!source) throw new HTTPException(404, { message: "Source page not found" }); - // 個人ページのみコピー元に許す。他人の個人ページや、すでにノートネイティブな - // ページは Phase 3 の「個人 → ノート」スコープ外(別ノートからの取り込みは別 API)。 - // Only the caller's own personal page can be the source for - // copy-from-personal. Cross-note adoption needs a different endpoint. - if (source.ownerId !== userId) { - throw new HTTPException(403, { message: "Forbidden" }); - } - if (source.noteId !== null) { - throw new HTTPException(400, { message: "Source page must be a personal page" }); - } - - const { pageId: newPageId, page: newPage } = await copyPageRowWithContent(tx, { - ownerId: userId, - destinationNoteId: noteId, - sourcePageId: source.id, - sourceMetadata: source, - }); + .returning(); - const maxOrder = await tx - .select({ max: sql`COALESCE(MAX(${notePages.sortOrder}), 0)` }) - .from(notePages) - .where(and(eq(notePages.noteId, noteId), eq(notePages.isDeleted, false))); - - const order = (maxOrder[0]?.max ?? 0) + 1; - - await tx.insert(notePages).values({ - noteId, - pageId: newPageId, - addedByUserId: userId, - sortOrder: order, - }); + const newPage = inserted[0]; + if (!newPage) throw new HTTPException(500, { message: "Failed to create page" }); await tx.update(notes).set({ updatedAt: new Date() }).where(eq(notes.id, noteId)); - - return { pageId: newPageId, sortOrder: order, page: newPage }; + return newPage; }); return c.json({ created: true, - page_id: result.pageId, - sort_order: result.sortOrder, - page: result.page, - }); -}); - -// ── POST /:noteId/pages/:pageId/copy-to-personal ──────────────────────────── -// ノートネイティブページ(`pages.note_id = :noteId`)の内容をコピーして -// 呼び出し元の個人ページ(`note_id = NULL`, `source_page_id = :pageId`)を作る。 -// 元ページはノートに残り、コピーだけが個人 /home に出る。脱退後もコピーは残る。 -// Issue #713 Phase 3。 -// -// Copy a note-native page (`pages.note_id = :noteId`) into the caller's -// personal pages as a fresh row (`note_id = NULL`, `source_page_id = :pageId`). -// The original stays in the note; only the copy lands on the caller's /home, -// and the copy survives if the caller later leaves the note. See issue #713. -app.post("/:noteId/pages/:pageId/copy-to-personal", authRequired, async (c) => { - const noteId = c.req.param("noteId"); - const sourcePageId = c.req.param("pageId"); - const userId = c.get("userId"); - const userEmail = c.get("userEmail"); - const db = c.get("db"); - - // 呼び出し元がノートを閲覧できることを確認する。`role` が解決できれば - // owner / member / domain / guest のいずれかに該当し、対応する個人コピーの - // 作成を許可する。`getNoteRole` 内部で `findActiveNoteById` まで引くので - // note 存在チェックを兼ねる。 - // - // Verify the caller can read this note (any resolved role — owner / member / - // domain / guest — is sufficient to take a personal copy). `getNoteRole` - // internally hits `findActiveNoteById`, which doubles as the 404 guard. - const { role, note } = await getNoteRole(noteId, userId, userEmail, db); - if (!note) throw new HTTPException(404, { message: "Note not found" }); - if (!role) throw new HTTPException(403, { message: "Forbidden" }); - - const result = await db.transaction(async (tx) => { - const sourceRows = await tx - .select({ - id: pages.id, - noteId: pages.noteId, - title: pages.title, - contentPreview: pages.contentPreview, - thumbnailUrl: pages.thumbnailUrl, - sourceUrl: pages.sourceUrl, - }) - .from(pages) - .where(and(eq(pages.id, sourcePageId), eq(pages.isDeleted, false))) - .limit(1); - - const source = sourceRows[0]; - if (!source) throw new HTTPException(404, { message: "Source page not found" }); - // URL のノート ID と実際のページ所属が食い違う場合は拒否する。これによって、 - // 別ノートのページ ID を使ってこのノートの閲覧権で取り込もうとする行為を封じる。 - // Reject if the URL note and the page's own note diverge. Otherwise a caller - // with access to note A could pass a page id from note B and launder its - // contents into their personal /home. - if (source.noteId !== noteId) { - throw new HTTPException(400, { message: "Page does not belong to this note" }); - } - - return copyPageRowWithContent(tx, { - ownerId: userId, - destinationNoteId: null, - sourcePageId: source.id, - sourceMetadata: source, - }); + page_id: created.id, + sort_order: 0, }); - - return c.json({ created: true, page_id: result.pageId, page: result.page }); }); // ── DELETE /:noteId/pages/:pageId ─────────────────────────────────────────── @@ -444,20 +95,6 @@ app.delete("/:noteId/pages/:pageId", authRequired, async (c) => { throw new HTTPException(403, { message: "Forbidden" }); } - // ノートからページを外す。ノートネイティブページ(`pages.note_id = noteId`)の場合は - // `note_pages` の論理削除だけだと `pages` 行が残り、`/api/pages/:id/content` などが - // ノートロール経由で引き続き認可してしまう(孤児化)。同一トランザクション内で - // `pages` 自体も論理削除して整合性を保つ。 - // 個人ページ(`pages.note_id IS NULL`)のリンク解除は従来どおり `note_pages` だけを - // 落とし、ページ自体は所有者の個人 /home に残す。 - // - // Detach a page from a note. For note-native pages - // (`pages.note_id = noteId`), tombstoning only `note_pages` would leave the - // `pages` row alive and still authorized via the note role on - // `/api/pages/:id/content`, etc. Soft-delete the `pages` row in the same - // transaction so the orphan goes away. For personal pages (`note_id IS NULL`) - // we still only drop the link row so the page stays on the owner's /home. - // See issue #713. await db.transaction(async (tx) => { const pageRow = await tx .select({ id: pages.id, noteId: pages.noteId }) @@ -465,26 +102,24 @@ app.delete("/:noteId/pages/:pageId", authRequired, async (c) => { .where(and(eq(pages.id, pageId), eq(pages.isDeleted, false))) .limit(1); - await tx - .update(notePages) - .set({ isDeleted: true, updatedAt: new Date() }) - .where(and(eq(notePages.noteId, noteId), eq(notePages.pageId, pageId))); - const page = pageRow[0]; - if (page && page.noteId === noteId) { - await tx - .update(pages) - .set({ isDeleted: true, updatedAt: new Date() }) - .where(eq(pages.id, pageId)); + if (!page) throw new HTTPException(404, { message: "Page not found" }); + if (page.noteId !== noteId) { + throw new HTTPException(400, { message: "Page does not belong to this note" }); } + await tx + .update(pages) + .set({ isDeleted: true, updatedAt: new Date() }) + .where(eq(pages.id, pageId)); + await tx.update(notes).set({ updatedAt: new Date() }).where(eq(notes.id, noteId)); }); return c.json({ removed: true }); }); -// ── PUT /:noteId/pages (reorder) ──────────────────────────────────────────── +// ── PUT /:noteId/pages (reorder noop) ─────────────────────────────────────── app.put("/:noteId/pages", authRequired, async (c) => { const noteId = c.req.param("noteId"); const userId = c.get("userId"); @@ -505,15 +140,7 @@ app.put("/:noteId/pages", authRequired, async (c) => { throw new HTTPException(400, { message: "page_ids array is required" }); } - for (let i = 0; i < body.page_ids.length; i++) { - const pageId = body.page_ids[i]; - if (!pageId) continue; - await db - .update(notePages) - .set({ sortOrder: i, updatedAt: new Date() }) - .where(and(eq(notePages.noteId, noteId), eq(notePages.pageId, pageId))); - } - + // Issue #823: sort order lives on `pages.updated_at` only; ignore payload. await db.update(notes).set({ updatedAt: new Date() }).where(eq(notes.id, noteId)); return c.json({ reordered: true }); @@ -532,20 +159,15 @@ app.get("/:noteId/pages", authRequired, async (c) => { const result = await db .select({ - page_id: notePages.pageId, - sort_order: notePages.sortOrder, - added_by: notePages.addedByUserId, + page_id: pages.id, page_title: pages.title, page_content_preview: pages.contentPreview, page_thumbnail_url: pages.thumbnailUrl, page_updated_at: pages.updatedAt, }) - .from(notePages) - .innerJoin(pages, eq(notePages.pageId, pages.id)) - .where( - and(eq(notePages.noteId, noteId), eq(notePages.isDeleted, false), eq(pages.isDeleted, false)), - ) - .orderBy(asc(notePages.sortOrder)); + .from(pages) + .where(and(eq(pages.noteId, noteId), eq(pages.isDeleted, false))) + .orderBy(desc(pages.updatedAt)); return c.json({ pages: result }); }); diff --git a/server/api/src/routes/notes/search.ts b/server/api/src/routes/notes/search.ts index d9901d5b..91700c3d 100644 --- a/server/api/src/routes/notes/search.ts +++ b/server/api/src/routes/notes/search.ts @@ -3,27 +3,18 @@ * * GET /:noteId/search?q=&limit= — 指定ノート内のページに限定した ILIKE 検索。 * - * スコープ契約 (Issue #713 / #718): - * - 結果は `note_pages` で当該ノートにひも付くページのみ。ノートネイティブ - * (`pages.note_id = :noteId`) もリンク済み個人ページ (`note_pages` で結ばれた - * `note_id IS NULL` の個人ページ) も両方含む。`p.note_id` を必ず返すので - * 呼び出し側は両者を区別できる。 - * - 閲覧権限は `getNoteRole` で解決し、任意のロール(owner / editor / viewer / - * guest)が解決できれば検索を許可する。private ノートの非メンバーは 403。 - * - クロススコープ検索(他ノート・個人 /home を横断)はこのエンドポイントでは - * 扱わない(混在グローバル検索は従来どおり `/api/search?scope=shared`)。 + * スコープ契約 (Issue #823): + * - 結果は `pages.note_id = :noteId` のページのみ。 * - * Scope contract (Issue #713 / #718): - * - Results are restricted to pages linked to this note via `note_pages` — - * both note-native pages (`pages.note_id = :noteId`) and linked personal - * pages show up through that table, since every "add to note" path writes a - * `note_pages` row. `p.note_id` is always included so callers can tell the - * two apart. - * - Read permission is resolved through `getNoteRole`; any resolved role - * (owner / editor / viewer / guest) allows searching. Non-members of a - * private note get 403. - * - Cross-scope lookups (spanning other notes or personal /home) are out of - * scope here; mixed global search still lives at `/api/search?scope=shared`. + * Scope contract (issue #823): + * - Results are restricted to rows where `pages.note_id` matches the path param. + * - Requires an authenticated session (`authRequired`). Access is decided via + * `getNoteRole`; any resolved role (owner/editor/viewer/guest) may search. + * Unauthenticated callers get 401; callers without a role on a private note get 403. + * + * - 認証済みセッション必須(`authRequired`)。閲覧権限は `getNoteRole` で解決し、 + * 解決されたロール(owner / editor / viewer / guest)があれば検索を許可する。 + * 未ログインは 401、private でロールなしは 403。 */ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; @@ -42,13 +33,8 @@ function escapeLike(input: string): string { /** * クエリ文字列の `limit` を有限の整数に正規化し、1〜100 の範囲へクランプする。 - * 非数値(`?limit=abc`)や `NaN` / 小数は既定値 20 にフォールバックさせて - * `LIMIT NaN` による SQL エラーを防ぐ。 * * Normalizes the `limit` query param to a finite integer clamped to 1..100. - * Non-numeric inputs (`?limit=abc`) and non-finite / fractional values fall - * back to the default 20 so a malformed query can't emit `LIMIT NaN` and 500 - * the request. */ function clampLimit(raw: string | undefined): number { const parsed = raw === undefined ? 20 : Number(raw); @@ -65,12 +51,6 @@ app.get("/:noteId/search", authRequired, async (c) => { const db = c.get("db"); const query = c.req.query("q")?.trim(); - // クエリが空なら DB を一切叩かずに即レスポンス。ノートの存在確認より前に - // 短絡するのは `/api/search` と同じ挙動で、フォーカスのたびに撃たれる検索が - // 無駄な権限解決で詰まらないようにするため。 - // Short-circuit before any DB work when q is empty, mirroring /api/search. - // Autocomplete-style UI hits this on every keystroke, so skipping even role - // resolution keeps the cost at zero. if (!query) { return c.json({ results: [] }); } @@ -82,25 +62,13 @@ app.get("/:noteId/search", authRequired, async (c) => { const limit = clampLimit(c.req.query("limit")); const pattern = `%${escapeLike(query)}%`; - // `note_pages` に書かれている = このノートで表示対象のページ。ノートネイティブ - // ページも、リンクされた個人ページも同じ経路で現れるので inner join で十分。 - // 返り値に `p.note_id` を含めて、呼び出し側が両者を判別できるようにする - // (Phase 5 契約)。 - // - // `note_pages` is the authoritative list of pages visible in a note, covering - // both note-native pages and linked personal pages. An inner join is enough; - // `p.note_id` is included so callers can still distinguish scope per row - // (Phase 5 contract). const results = await db.execute(sql` SELECT p.id, p.title, p.content_preview, p.updated_at, p.note_id, pc.content_text FROM pages p - INNER JOIN note_pages np - ON np.page_id = p.id - AND np.note_id = ${noteId} - AND np.is_deleted = false LEFT JOIN page_contents pc ON pc.page_id = p.id WHERE p.is_deleted = false + AND p.note_id = ${noteId} AND ( p.title ILIKE ${pattern} OR pc.content_text ILIKE ${pattern} diff --git a/server/api/src/routes/notes/types.ts b/server/api/src/routes/notes/types.ts index e509e703..f1406d7f 100644 --- a/server/api/src/routes/notes/types.ts +++ b/server/api/src/routes/notes/types.ts @@ -65,27 +65,16 @@ export interface NoteListApiItem extends NoteApiFields { } /** - * `GET /api/notes/:id` のページ行。`note_id` が NULL なら個人ページがこのノート - * にリンクされているだけ、値ありならノートネイティブページ。 - * Page row returned inside `GET /api/notes/:id`. `note_id` = null → linked - * personal page; non-null → note-native page. + * `GET /api/notes/:id` のページ行。Issue #823 以降、ページは常に 1 つのノートに所属し、 + * `note_id` はこのレスポンスのノート ID と一致する。 + * + * Page row inside `GET /api/notes/:id`. After issue #823 every page belongs to + * exactly one note; `note_id` matches the enclosing note id. */ export interface NotePageApiItem { id: string; owner_id: string; - /** - * ページのスコープ。`null` なら個人ページがこのノートに「リンク」されている - * だけ(所有者の /home にも現れる)。値ありなら、このノートに所属するノート - * ネイティブページ (`pages.note_id = noteId`)。クライアントはこれを見て - * 「個人に取り込み」のような note-native 専用アクションを出し分ける。 - * Issue #713 Phase 3。 - * - * Page scope. `null` means the page is a linked personal page (still on the - * owner's /home). A non-null value means a note-native page owned by this - * note (`pages.note_id = noteId`). Clients use this to gate note-native-only - * actions such as "copy to personal". See issue #713 Phase 3. - */ - note_id: string | null; + note_id: string; source_page_id: string | null; title: string | null; content_preview: string | null; @@ -94,20 +83,14 @@ export interface NotePageApiItem { created_at: Date; updated_at: Date; is_deleted: boolean; - sort_order: number; - added_by_user_id: string; - added_at: Date; } /** - * `GET /api/notes/:id` のレスポンス。呼び出し元の解決ロールと、このノート - * 表示に含まれる全ページ(リンクされた個人ページ + ノートネイティブ)を含む。 - * `note_id` が NULL の行はリンクされた個人ページ(所有者の /home にも出る)、 - * 値ありの行はこのノートに所属するノートネイティブページ。 - * `GET /api/notes/:id` response: caller's resolved role plus every page shown - * in this note view (linked personal + note-native). A `note_id` of `null` - * means a linked personal page (also on the owner's `/home`); a non-null - * value means a note-native page owned by this note. + * `GET /api/notes/:id` のレスポンス。呼び出し元の解決ロールと、`pages.note_id = id` + * の全ページを含む。 + * + * `GET /api/notes/:id` response: caller's resolved role plus every page with + * `pages.note_id` equal to this note id. */ export interface NoteDetailApiResponse extends NoteApiFields { current_user_role: NonNullable; diff --git a/server/api/src/routes/pageSnapshots.ts b/server/api/src/routes/pageSnapshots.ts index ecf3f5c9..516c1dd5 100644 --- a/server/api/src/routes/pageSnapshots.ts +++ b/server/api/src/routes/pageSnapshots.ts @@ -118,20 +118,13 @@ app.get("/:id/snapshots/:snapshotId", authRequired, async (c) => { * 他のページ書き込み系エンドポイント(`PUT /api/pages/:id/content` など)と * 同じく `assertPageEditAccess` に委譲する。 * - * - 個人ページ(`pages.note_id IS NULL`): `pages.ownerId` 一致のみ - * - ノートネイティブページ(`pages.note_id IS NOT NULL`): ノートロール + - * `editPermission` の `canEdit` 評価(issue #713)。これにより、ノートを抜けた - * 元作成者が restore を継続できてしまう問題と、ノートオーナーが他メンバー作成 - * ページを restore できない問題の両方が解消される。 + * - Issue #823 以降、すべてのページは `pages.note_id` でノートに所属する。復元は + * `assertPageEditAccess`(所属ノートの `canEdit`)で判定する。 * - * Restore a snapshot. Edit permission is required and is now delegated to - * `assertPageEditAccess`, the same helper used by `PUT /api/pages/:id/content`. - * - * - Personal page (`pages.note_id IS NULL`): only the `pages.ownerId` user. - * - Note-native page (`pages.note_id IS NOT NULL`): the caller's note role - * must satisfy `canEdit` against the note's `editPermission` (issue #713). - * This both prevents removed members from continuing to restore and lets - * note owners restore snapshots on pages created by other editors. + * Restore a snapshot. Edit permission is delegated to `assertPageEditAccess` + * (`canEdit` on the owning note). Every page belongs to a note (issue #823). + * This prevents removed members from restoring while allowing note owners to + * restore snapshots on pages created by other editors when policy allows. * * **Collaboration / コラボレーション**: This endpoint acquires a DB row lock for `page_contents` * and then asks Hocuspocus to invalidate the live document after commit. Configure diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index a1f31edf..7030afe1 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -1,8 +1,10 @@ /** * /api/pages — ページ CRUD + コンテンツ管理 * - * GET /api/pages — 自分 (own) のページ一覧、または共有 (shared) を含めた一覧をページネーション取得 - * — List the caller's pages (own, or own + shared via notes) with limit/offset pagination. + * GET /api/pages — 後方互換のページ一覧(Issue #823 以降は `Deprecation: true`)。 + * 新規実装は `GET /api/notes/me` → `GET /api/notes/:noteId/pages` を推奨。 + * — Legacy page listing (sends `Deprecation: true` after issue #823). + * Prefer `GET /api/notes/me` then `GET /api/notes/:noteId/pages` for new clients. * GET /api/pages/:id/content — Y.Doc コンテンツ取得(`page_contents` 行が未作成の空ページは 200 + 空 ydoc) * — Retrieve Y.Doc content (200 + empty `ydoc_state` when no `page_contents` row). * PUT /api/pages/:id/content — Y.Doc コンテンツ更新 (楽観的ロック) / Update with optimistic locking @@ -15,6 +17,9 @@ import { eq, and, sql } from "drizzle-orm"; import { pages, pageContents } from "../schema/index.js"; import { authRequired } from "../middleware/auth.js"; import type { AppEnv, Database } from "../types/index.js"; +import { ensureDefaultNote, getDefaultNoteOrNull } from "../services/defaultNoteService.js"; +import { getNoteRole, canEdit } from "./notes/helpers.js"; +import { extractEmailDomain } from "../lib/freeEmailDomains.js"; import { maybeCreateSnapshot } from "../services/snapshotService.js"; import { assertPageViewAccess, assertPageEditAccess } from "../services/pageAccessService.js"; import { propagateTitleRename } from "../services/titleRenamePropagationService.js"; @@ -115,94 +120,78 @@ async function applyPagesMetadataUpdate( } // ── GET /pages ────────────────────────────────────────────────────────────── -// `scope=shared` の場合、`/api/search` と同じ認可ロジック -// (own + 受諾済みノートメンバー + note owner) を流用する。 -// When `scope=shared`, reuses the same authorization model as `/api/search` -// (own pages + accepted note members + note owners). +// Issue #823: 一覧は `pages.note_id` モデルで再実装。MCP `zedi_list_pages` 等の後方互換のため +// 200 で返しつつ `Deprecation: true` を付与する。新規クライアントはノート配下エンドポイントへ。 +// +// Issue #823: reimplemented listing on `pages.note_id`. Keeps HTTP 200 for MCP / legacy callers +// while setting `Deprecation: true`; new clients should use note-scoped routes. app.get("/", authRequired, async (c) => { const userId = c.get("userId"); + const userEmailRaw = c.get("userEmail"); const db = c.get("db"); - // クエリパラメータは整数として明示的にパースする。`Number("abc")` だと NaN が SQL に渡るため。 - // Parse query params as integers — `Number("abc")` would propagate NaN into SQL. const limit = Math.min(Math.max(parseInt(c.req.query("limit") ?? "20", 10) || 20, 1), 100); const offset = Math.max(parseInt(c.req.query("offset") ?? "0", 10) || 0, 0); const scope = c.req.query("scope") === "shared" ? "shared" : "own"; - - // アクセス制御だけを変数化して SELECT 文の重複を避ける。 - // `shared` は `services/pageAccessService.ts` と同じ正規の認可モデルを採用: - // - notes が未削除であること - // - note_members.status = 'accepted' (招待を受諾済み) であること - // - note_members / note_pages が未削除であること - // 大規模データセットでもプランナーが効きやすい EXISTS + JOIN を使う。 - // Vary only the access predicate to avoid duplicating the SELECT. - // `shared` mirrors the canonical authorization model from `services/pageAccessService.ts`: - // the linked note must be active, the membership must be accepted, and the join rows - // must not be soft-deleted. EXISTS + JOIN keeps the planner happy on large datasets. - // `own` スコープは個人ページ(`pages.note_id IS NULL`)のみを返す。 - // ノートネイティブページ(issue #713)は、ノート画面または `scope=shared` - // 経由でのみアクセスする。`shared` 経由の場合は (a) note_members 経由の - // メンバーシップ、または (b) `note_pages -> notes.owner_id = userId` 経由の - // オーナーシップで含まれる。オーナー経路を note-native page だけに限定すると、 - // linked personal page が listing から消えて `assertPageViewAccess` と非対称になる。 - // `getNoteRole` の解決順 (owner → member → ...) と listing predicate を揃える。 - // - // The `own` scope returns personal pages only (`pages.note_id IS NULL`). - // Note-native pages (issue #713) are accessed via the note view or - // `scope=shared`. `shared` includes them either through (a) `note_members` - // membership or (b) note ownership reached through `note_pages`. That owner - // branch must cover linked personal pages too; otherwise owners could open - // them via `assertPageViewAccess` while the listing hides them. - const accessFilter = - scope === "shared" - ? sql`( - (p.owner_id = ${userId} AND p.note_id IS NULL) - OR EXISTS ( - SELECT 1 FROM note_pages np - JOIN notes n ON n.id = np.note_id - JOIN note_members nm ON nm.note_id = np.note_id - JOIN "user" u ON u.email = nm.member_email - WHERE np.page_id = p.id - AND u.id = ${userId} - AND nm.status = 'accepted' - AND nm.is_deleted = false - AND np.is_deleted = false - AND n.is_deleted = false - ) - OR EXISTS ( - SELECT 1 FROM note_pages np - JOIN notes n ON n.id = np.note_id - WHERE np.page_id = p.id - AND np.is_deleted = false - AND n.owner_id = ${userId} - AND n.is_deleted = false - ) - )` - : sql`p.owner_id = ${userId} AND p.note_id IS NULL`; - - // Wiki の内部システムページ(`special_kind` が `__index__` / `__log__`、 - // および `is_schema = true` のスキーマページ)は通常一覧から除外する。 - // クライアントがそれらを編集するための専用 UI が別にあるため、`/api/pages` - // で返すと NotFound 化したり、ヘッダ付きカードの中に編集不能な行が混ざる。 - // include_special=true を指定したクライアントのみオプトインで取得できる。 - // Hide internal/system pages (special_kind set or is_schema=true) from the - // generic listing; clients that need them can opt in with include_special=true. const includeSpecial = c.req.query("include_special") === "true"; + const specialKindFilter = includeSpecial ? sql`TRUE` : sql`p.special_kind IS NULL AND p.is_schema = false`; - // `note_id` を返すことで、`scope=shared` で混在 listing を受け取った - // クライアントが個人ページ(`note_id IS NULL`)とノートネイティブページを - // 区別できる。MCP の `zedi_list_pages` ツールはこれに依存している。 - // Surface `note_id` so callers receiving mixed `scope=shared` results (e.g. - // the `zedi_list_pages` MCP tool) can distinguish personal vs note-native. + c.header("Deprecation", "true"); + + const normalizedEmail = typeof userEmailRaw === "string" ? userEmailRaw.trim().toLowerCase() : ""; + const emailDomain = extractEmailDomain(normalizedEmail); + + const domainBranch = + emailDomain !== null + ? sql`OR EXISTS ( + SELECT 1 + FROM notes n + INNER JOIN note_domain_access nda ON nda.note_id = n.id + WHERE n.id = p.note_id + AND n.is_deleted = false + AND nda.is_deleted = false + AND nda.domain = ${emailDomain} + )` + : sql``; + + let accessFilter; + + if (scope === "own") { + const defaultNote = await getDefaultNoteOrNull(db, userId); + if (!defaultNote) { + return c.json({ pages: [] }); + } + accessFilter = sql`p.note_id = ${defaultNote.id}`; + } else { + accessFilter = sql`( + EXISTS ( + SELECT 1 FROM notes n + WHERE n.id = p.note_id AND n.is_deleted = false AND n.owner_id = ${userId} + ) + OR EXISTS ( + SELECT 1 + FROM notes n + INNER JOIN note_members nm ON nm.note_id = n.id + INNER JOIN "user" u ON LOWER(u.email) = LOWER(nm.member_email) + WHERE n.id = p.note_id + AND u.id = ${userId} + AND nm.status = 'accepted' + AND nm.is_deleted = false + AND n.is_deleted = false + ) + ${domainBranch} + )`; + } + const result = await db.execute(sql` SELECT p.id, p.title, p.content_preview, p.updated_at, p.note_id FROM pages p WHERE p.is_deleted = false - AND ${specialKindFilter} - AND ${accessFilter} + AND ${specialKindFilter} + AND ${accessFilter} ORDER BY p.updated_at DESC LIMIT ${limit} OFFSET ${offset} @@ -217,10 +206,8 @@ app.get("/:id/content", authRequired, async (c) => { const userId = c.get("userId"); const db = c.get("db"); - // 個人ページは所有者のみ、ノートネイティブページはノートのロール解決 - // (member / domain / public guest)が成立すれば閲覧可。Issue #713 を参照。 - // Personal pages: owner only. Note-native pages: any resolved note role - // (member / domain / public guest) may view. See issue #713. + // すべてのページはノート所属。閲覧は `getNoteRole(pages.note_id)` が成立すれば可。 + // Every page belongs to a note; viewing requires a resolved note role on `pages.note_id`. await assertPageViewAccess(db, pageId, userId); // コンテンツ取得 @@ -273,10 +260,8 @@ app.put("/:id/content", authRequired, async (c) => { throw new HTTPException(400, { message: "ydoc_state is required" }); } - // 個人ページは所有者のみ、ノートネイティブページは note ロール / editPermission - // で判定する。Issue #713 を参照。 - // Personal pages: owner only. Note-native pages: note role + editPermission - // (`canEdit`). See issue #713. + // 編集はノートロール + `editPermission` (`canEdit`) で判定する。 + // Editing requires note role + `canEdit` against the owning note. await assertPageEditAccess(db, pageId, userId); const ydocBuffer = Buffer.from(body.ydoc_state, "base64"); @@ -424,6 +409,8 @@ app.post("/", authRequired, async (c) => { const db = c.get("db"); const body = await c.req.json<{ + /** 省略時は呼び出し元のデフォルトノート(マイノート)へ所属させる。 */ + note_id?: string | null; title?: string; content_preview?: string; source_page_id?: string; @@ -440,10 +427,25 @@ app.post("/", authRequired, async (c) => { thumbnail_object_id?: string | null; }>(); + let resolvedNoteId = + typeof body.note_id === "string" && body.note_id.trim() !== "" ? body.note_id.trim() : null; + if (!resolvedNoteId) { + const defaultNote = await ensureDefaultNote(db, userId); + resolvedNoteId = defaultNote.id; + } else { + const userEmail = c.get("userEmail"); + const { role, note } = await getNoteRole(resolvedNoteId, userId, userEmail, db); + if (!note) throw new HTTPException(404, { message: "Note not found" }); + if (!role || !canEdit(role, note)) { + throw new HTTPException(403, { message: "Forbidden" }); + } + } + const result = await db .insert(pages) .values({ ownerId: userId, + noteId: resolvedNoteId, title: body.title ?? null, contentPreview: body.content_preview ?? null, sourcePageId: body.source_page_id ?? null, @@ -478,9 +480,8 @@ app.delete("/:id", authRequired, async (c) => { const userId = c.get("userId"); const db = c.get("db"); - // ノートネイティブページの削除はノート編集権限で判定する。 - // Note-native page deletion is governed by the note's edit permission. - // See issue #713. + // ページ削除は所属ノートの編集権限で判定する。 + // Page deletion is governed by edit permission on the owning note. await assertPageEditAccess(db, pageId, userId); // GC 対象のサムネイル ID とページオーナーを取りつつ、ページを soft-delete する。 diff --git a/server/api/src/routes/search.ts b/server/api/src/routes/search.ts index 0d29984a..adeb044f 100644 --- a/server/api/src/routes/search.ts +++ b/server/api/src/routes/search.ts @@ -3,24 +3,22 @@ * * GET /api/search?q=&scope= — ILIKE による全文検索 (pg_trgm GIN インデックスで高速化) * - * スコープ契約 (Issue #713 / #718 Phase 5-1): - * - `scope=own` は個人ページ (`note_id IS NULL`) のみを返す。Phase 1〜4 で導入された - * 個人 / ノートネイティブページの分離を検索面にも反映するための防御的ガード。 - * - `scope=shared` は個人ページ + 自分が参加するノートのページを横断する既存挙動を維持する。 - * - いずれのスコープでも `note_id` を返し、呼び出し側がスコープ判定できるようにする。 + * スコープ契約 (Issue #823): + * - `scope=own` は呼び出し元のデフォルトノート(マイノート)配下のページのみ。 + * - `scope=shared` はオーナー / 受諾済みメンバー / ドメインルールでアクセス可能な + * ノートに所属するページを横断する。 * - * Scope contract (Issue #713 / #718 Phase 5-1): - * - `scope=own` returns personal pages only (`note_id IS NULL`). This is a - * defensive guard that mirrors the personal / note-native split introduced - * in Phase 1〜4 at the search layer. - * - `scope=shared` keeps the existing cross-scope behavior (personal pages + - * pages in notes the caller participates in). - * - Both scopes expose `note_id` so callers can tell the two apart. + * Scope contract (issue #823): + * - `scope=own` restricts to pages under the caller's default note. + * - `scope=shared` spans pages in notes the caller can access (owner, accepted + * member, or domain rule). */ import { Hono } from "hono"; import { sql } from "drizzle-orm"; import { authRequired } from "../middleware/auth.js"; import type { AppEnv } from "../types/index.js"; +import { extractEmailDomain } from "../lib/freeEmailDomains.js"; +import { getDefaultNoteOrNull } from "../services/defaultNoteService.js"; function escapeLike(input: string): string { return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); @@ -36,6 +34,7 @@ const app = new Hono(); app.get("/", authRequired, async (c) => { const userId = c.get("userId"); + const userEmailRaw = c.get("userEmail"); const db = c.get("db"); const query = c.req.query("q")?.trim(); @@ -47,11 +46,25 @@ app.get("/", authRequired, async (c) => { const limit = clampLimit(c.req.query("limit")); const pattern = `%${escapeLike(query)}%`; - // 両スコープで返す列は同一なので共有する。`p.note_id` は呼び出し側のスコープ判定用。 - // Both scopes return the same columns; `p.note_id` lets callers distinguish scopes. const searchColumns = sql`p.id, p.title, p.content_preview, p.updated_at, p.note_id, pc.content_text`; + const normalizedEmail = typeof userEmailRaw === "string" ? userEmailRaw.trim().toLowerCase() : ""; + const emailDomain = extractEmailDomain(normalizedEmail); + + const domainPredicate = + emailDomain !== null + ? sql`OR EXISTS ( + SELECT 1 + FROM notes n + INNER JOIN note_domain_access nda ON nda.note_id = n.id + WHERE n.id = p.note_id + AND n.is_deleted = false + AND nda.is_deleted = false + AND nda.domain = ${emailDomain} + )` + : sql``; + let results; if (scope === "shared") { @@ -61,29 +74,22 @@ app.get("/", authRequired, async (c) => { LEFT JOIN page_contents pc ON pc.page_id = p.id WHERE p.is_deleted = false AND ( - (p.owner_id = ${userId} AND p.note_id IS NULL) + EXISTS ( + SELECT 1 FROM notes n + WHERE n.id = p.note_id AND n.is_deleted = false AND n.owner_id = ${userId} + ) OR EXISTS ( SELECT 1 - FROM note_pages np - JOIN notes n ON n.id = np.note_id - JOIN note_members nm ON nm.note_id = np.note_id - JOIN "user" u ON u.email = nm.member_email - WHERE np.page_id = p.id + FROM notes n + INNER JOIN note_members nm ON nm.note_id = n.id + INNER JOIN "user" u ON LOWER(u.email) = LOWER(nm.member_email) + WHERE n.id = p.note_id AND u.id = ${userId} AND nm.status = 'accepted' AND nm.is_deleted = false - AND np.is_deleted = false - AND n.is_deleted = false - ) - OR EXISTS ( - SELECT 1 - FROM note_pages np - JOIN notes n ON n.id = np.note_id - WHERE np.page_id = p.id - AND np.is_deleted = false - AND n.owner_id = ${userId} AND n.is_deleted = false ) + ${domainPredicate} ) AND ( p.title ILIKE ${pattern} @@ -93,13 +99,16 @@ app.get("/", authRequired, async (c) => { LIMIT ${limit} `); } else { + const defaultNote = await getDefaultNoteOrNull(db, userId); + if (!defaultNote) { + return c.json({ results: [] }); + } results = await db.execute(sql` SELECT ${searchColumns} FROM pages p LEFT JOIN page_contents pc ON pc.page_id = p.id WHERE p.is_deleted = false - AND p.owner_id = ${userId} - AND p.note_id IS NULL + AND p.note_id = ${defaultNote.id} AND ( p.title ILIKE ${pattern} OR pc.content_text ILIKE ${pattern} diff --git a/server/api/src/routes/syncPages.ts b/server/api/src/routes/syncPages.ts index 51a3e6c2..3844ed03 100644 --- a/server/api/src/routes/syncPages.ts +++ b/server/api/src/routes/syncPages.ts @@ -3,13 +3,22 @@ * * GET /api/sync/pages — 差分ページ取得 (since クエリパラメータ) * POST /api/sync/pages — ページ + リンク バルク同期 + * + * Issue #823: 旧「個人ページ」(`note_id IS NULL`)は廃止。同期対象は呼び出し元の + * **デフォルトノート(マイノート)**所属かつ `owner_id = userId` のページに限定する。 + * フロント差し替え完了まで、古いクライアントは GET が空になり得る。 + * + * Issue #823: legacy personal pages (`note_id IS NULL`) are gone. Sync targets rows + * in the caller's **default note** with `owner_id = userId`. Older clients may see + * empty GET responses until the frontend migrates. */ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; -import { eq, and, gt, inArray, isNull } from "drizzle-orm"; +import { eq, and, gt, inArray } from "drizzle-orm"; import { pages, links, ghostLinks, LINK_TYPES, type LinkType } from "../schema/index.js"; import { authRequired } from "../middleware/auth.js"; import type { AppEnv } from "../types/index.js"; +import { ensureDefaultNote } from "../services/defaultNoteService.js"; /** * `body.links` / `body.ghost_links` で受け取った `link_type` を正規化する。 @@ -32,19 +41,16 @@ function normalizeLinkType(value: unknown): LinkType { const app = new Hono(); // ── GET /sync/pages ───────────────────────────────────────────────────────── -// 個人ページ同期はクライアントの IndexedDB と個人ページ (`pages.note_id IS -// NULL`) のみを対象にする。ノートネイティブページ(issue #713)はサーバー側 -// で管理され、ノート画面/共同編集経由でのみアクセスする。 +// Issue #823: デフォルトノート所属ページのみクライアント IndexedDB と同期する。 // -// Personal-page sync only mirrors personal pages (`pages.note_id IS NULL`) -// into the client's IndexedDB. Note-native pages (issue #713) live solely on -// the server and are accessed through the note view / collaborative editor. +// Issue #823: only pages under the user's default note sync to IndexedDB. app.get("/", authRequired, async (c) => { const userId = c.get("userId"); const db = c.get("db"); const since = c.req.query("since"); - const personalPageFilter = and(eq(pages.ownerId, userId), isNull(pages.noteId)); + const defaultNote = await ensureDefaultNote(db, userId); + const personalPageFilter = and(eq(pages.ownerId, userId), eq(pages.noteId, defaultNote.id)); let query = db .select({ @@ -160,19 +166,15 @@ app.post("/", authRequired, async (c) => { link_type: normalizeLinkType(g.link_type), })); + const defaultNote = await ensureDefaultNote(db, userId); + const defaultNoteId = defaultNote.id; + const results: Array<{ id: string; action: string }> = []; // ページごとに LWW (Last Write Wins) 同期。 - // 同期対象は個人ページ(`pages.note_id IS NULL`)のみ。ノートネイティブ - // ページ(issue #713)や他人の個人ページは衝突回避のため `skipped` 扱い。 - // - // バルク取得で N+1 を避ける: クライアントから来た全 ID を一括で引き、 - // メモリ上で「未存在 / 自分の個人ページ / それ以外(ノートネイティブ or - // 他人)」に振り分けてから個別の DML を発行する。 + // Issue #823: 対象はデフォルトノート所属かつ `owner_id = userId` のページのみ。 // - // LWW sync runs only against personal pages (`pages.note_id IS NULL`). - // Note-native pages (issue #713) and other users' personal pages are skipped - // to avoid ID collisions and IDOR. + // LWW sync runs only for pages in the caller's default note owned by the caller. // // Bulk-load to avoid N+1: fetch every incoming id in one query, then classify // in memory as "missing", "owned personal", or "other (note-native or @@ -206,7 +208,7 @@ app.post("/", authRequired, async (c) => { noteId: string | null; updatedAt: Date; }; - const existingRows: ExistingRow[] = + const existingRaw = incomingIds.length > 0 ? await db .select({ @@ -218,6 +220,7 @@ app.post("/", authRequired, async (c) => { .from(pages) .where(inArray(pages.id, incomingIds)) : []; + const existingRows: ExistingRow[] = existingRaw; const existingMap = new Map(existingRows.map((row) => [row.id, row])); for (const p of latestIncomingById.values()) { @@ -228,6 +231,7 @@ app.post("/", authRequired, async (c) => { await db.insert(pages).values({ id: p.id, ownerId: userId, + noteId: defaultNoteId, title: p.title ?? null, contentPreview: p.content_preview ?? null, thumbnailUrl: p.thumbnail_url ?? null, @@ -243,16 +247,16 @@ app.post("/", authRequired, async (c) => { existingMap.set(p.id, { id: p.id, ownerId: userId, - noteId: null, + noteId: defaultNoteId, updatedAt: clientTime, }); results.push({ id: p.id, action: "created" }); continue; } - // ノートネイティブ or 他人の個人ページは触らない - // Skip note-native rows or rows owned by another user - if (existing.noteId !== null || existing.ownerId !== userId) { + // デフォルトノート以外 or 他人ページは触らない + // Skip rows outside the default note or owned by another user + if (existing.ownerId !== userId || existing.noteId !== defaultNoteId) { results.push({ id: p.id, action: "skipped" }); continue; } @@ -269,7 +273,7 @@ app.post("/", authRequired, async (c) => { isDeleted: p.is_deleted ?? false, updatedAt: clientTime, }) - .where(and(eq(pages.id, p.id), eq(pages.ownerId, userId), isNull(pages.noteId))); + .where(and(eq(pages.id, p.id), eq(pages.ownerId, userId), eq(pages.noteId, defaultNoteId))); existingMap.set(p.id, { ...existing, updatedAt: clientTime }); results.push({ id: p.id, action: "updated" }); } else { @@ -292,10 +296,17 @@ app.post("/", authRequired, async (c) => { // "missing source_id → no delete" semantics along the link_type axis. if (incomingLinks.length > 0) { const sourceIds = [...new Set(incomingLinks.map((l) => l.source_id))]; - const ownedPages = await db + const ownedPagesRaw = await db .select({ id: pages.id }) .from(pages) - .where(and(eq(pages.ownerId, userId), isNull(pages.noteId), inArray(pages.id, sourceIds))); + .where( + and( + eq(pages.ownerId, userId), + eq(pages.noteId, defaultNoteId), + inArray(pages.id, sourceIds), + ), + ); + const ownedPages = ownedPagesRaw; const ownedIds = new Set(ownedPages.map((r) => r.id)); const deletePairs = new Set(); @@ -327,10 +338,17 @@ app.post("/", authRequired, async (c) => { // Ghost link sync — personal pages only (同じ link_type スコープ化方針) if (incomingGhostLinks.length > 0) { const sourceIds = [...new Set(incomingGhostLinks.map((g) => g.source_page_id))]; - const ownedGhostPages = await db + const ownedGhostRaw = await db .select({ id: pages.id }) .from(pages) - .where(and(eq(pages.ownerId, userId), isNull(pages.noteId), inArray(pages.id, sourceIds))); + .where( + and( + eq(pages.ownerId, userId), + eq(pages.noteId, defaultNoteId), + inArray(pages.id, sourceIds), + ), + ); + const ownedGhostPages = ownedGhostRaw; const ownedGhostIds = new Set(ownedGhostPages.map((r) => r.id)); const deletePairs = new Set(); diff --git a/server/api/src/routes/wikiSchema.ts b/server/api/src/routes/wikiSchema.ts index 03c8c06e..ba7fa567 100644 --- a/server/api/src/routes/wikiSchema.ts +++ b/server/api/src/routes/wikiSchema.ts @@ -16,6 +16,7 @@ import { authRequired } from "../middleware/auth.js"; import { pages } from "../schema/pages.js"; import { pageContents } from "../schema/pageContents.js"; import { recordActivity } from "../services/activityLogService.js"; +import { ensureDefaultNote } from "../services/defaultNoteService.js"; import type { AppEnv } from "../types/index.js"; const app = new Hono(); @@ -108,10 +109,12 @@ app.put("/", authRequired, async (c) => { await tx.update(pages).set({ title, updatedAt: now }).where(eq(pages.id, existing.id)); resolvedPageId = existing.id; } else { + const defaultNote = await ensureDefaultNote(tx, userId); const [newPage] = await tx .insert(pages) .values({ ownerId: userId, + noteId: defaultNote.id, title, isSchema: true, createdAt: now, diff --git a/server/api/src/schema/index.ts b/server/api/src/schema/index.ts index 2299803f..d12b693f 100644 --- a/server/api/src/schema/index.ts +++ b/server/api/src/schema/index.ts @@ -16,7 +16,6 @@ export { } from "./userOnboardingStatus.js"; export { notes, - notePages, noteMembers, noteInvitations, noteInviteLinks, @@ -24,8 +23,6 @@ export { noteDomainAccess, type Note, type NewNote, - type NotePage, - type NewNotePage, type NoteMember, type NewNoteMember, type NoteInvitation, @@ -105,7 +102,6 @@ export { accountRelations, pagesRelations, notesRelations, - notePagesRelations, noteMembersRelations, noteInvitationsRelations, noteInviteLinksRelations, diff --git a/server/api/src/schema/notes.ts b/server/api/src/schema/notes.ts index 48874398..81dc0178 100644 --- a/server/api/src/schema/notes.ts +++ b/server/api/src/schema/notes.ts @@ -12,7 +12,6 @@ import { } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; import { users } from "./users.js"; -import { pages } from "./pages.js"; /** * ノート(複数ページのまとまり)。 @@ -76,46 +75,7 @@ export type Note = typeof notes.$inferSelect; /** Insert type for the notes table. / notes テーブルの INSERT 型。 */ export type NewNote = typeof notes.$inferInsert; -export /** - * - */ -const notePages = pgTable( - "note_pages", - { - noteId: uuid("note_id") - .notNull() - .references(() => notes.id, { onDelete: "cascade" }), - pageId: uuid("page_id") - .notNull() - .references(() => pages.id, { onDelete: "cascade" }), - addedByUserId: text("added_by_user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - sortOrder: integer("sort_order").notNull().default(0), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), - isDeleted: boolean("is_deleted").default(false).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.noteId, table.pageId] }), - index("idx_note_pages_note_id").on(table.noteId), - index("idx_note_pages_page_id").on(table.pageId), - ], -); - -/** - * - */ -export type NotePage = typeof notePages.$inferSelect; -/** - * - */ -export type NewNotePage = typeof notePages.$inferInsert; - -export /** - * - */ -const noteMembers = pgTable( +export const noteMembers = pgTable( "note_members", { noteId: uuid("note_id") diff --git a/server/api/src/schema/pages.ts b/server/api/src/schema/pages.ts index 8f3a4371..dc63ce2c 100644 --- a/server/api/src/schema/pages.ts +++ b/server/api/src/schema/pages.ts @@ -40,16 +40,15 @@ export const pages = pgTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), /** - * 所属ノート ID。NULL は個人ページ(旧モデル)、値ありはそのノートに - * 所属するノートネイティブページ。デフォルトノート移行(PR 1b)後は - * NOT NULL に昇格させ、すべてのページがノート所属になる予定。 - * Issue #713 を参照。 + * 所属ノート ID。すべてのページはちょうど 1 つのノートに属する(Issue #823)。 + * ユーザーの「個人スペース」はデフォルトノート(`notes.is_default`)のページ群。 * - * Owning note ID. NULL is a legacy "personal page"; a non-null value is a - * note-native page. PR 1b will backfill personal pages into each user's - * default note and promote this column to NOT NULL. See issue #713. + * Owning note ID. Every page belongs to exactly one note (issue #823). A + * user's personal space is the page set under their default note. */ - noteId: uuid("note_id").references(() => notes.id, { onDelete: "cascade" }), + noteId: uuid("note_id") + .notNull() + .references(() => notes.id, { onDelete: "cascade" }), sourcePageId: uuid("source_page_id"), title: text("title"), contentPreview: text("content_preview"), @@ -110,10 +109,8 @@ export const pages = pgTable( */ index("idx_pages_thumbnail_object_id").on(table.thumbnailObjectId), /** - * Lookup of pages owned by a particular note (and an efficient predicate - * for "personal pages only" via `note_id IS NULL` / `IS NOT NULL`). - * 特定のノートに所属するページの引きと、`note_id IS NULL`/`IS NOT NULL` の - * 部分述語に効くインデックス。 + * Lookup of pages by owning note (`pages.note_id`). + * 所属ノート別のページ引き用インデックス。 */ index("idx_pages_note_id").on(table.noteId), /** diff --git a/server/api/src/schema/relations.ts b/server/api/src/schema/relations.ts index f3adc0fa..e2ca3d71 100644 --- a/server/api/src/schema/relations.ts +++ b/server/api/src/schema/relations.ts @@ -3,7 +3,6 @@ import { users, session, account } from "./users.js"; import { pages } from "./pages.js"; import { notes, - notePages, noteMembers, noteInvitations, noteInviteLinks, @@ -79,7 +78,6 @@ const pagesRelations = relations(pages, ({ one, many }) => ({ fields: [pages.noteId], references: [notes.id], }), - notePages: many(notePages), snapshots: many(pageSnapshots), media: many(media), outgoingLinks: many(links, { relationName: "sourceLinks" }), @@ -96,35 +94,13 @@ const notesRelations = relations(notes, ({ one, many }) => ({ references: [users.id], }), pages: many(pages), - notePages: many(notePages), noteMembers: many(noteMembers), noteInvitations: many(noteInvitations), noteInviteLinks: many(noteInviteLinks), noteDomainAccess: many(noteDomainAccess), })); -export /** - * - */ -const notePagesRelations = relations(notePages, ({ one }) => ({ - note: one(notes, { - fields: [notePages.noteId], - references: [notes.id], - }), - page: one(pages, { - fields: [notePages.pageId], - references: [pages.id], - }), - addedBy: one(users, { - fields: [notePages.addedByUserId], - references: [users.id], - }), -})); - -export /** - * - */ -const noteMembersRelations = relations(noteMembers, ({ one }) => ({ +export const noteMembersRelations = relations(noteMembers, ({ one }) => ({ note: one(notes, { fields: [noteMembers.noteId], references: [notes.id], diff --git a/server/api/src/services/defaultNoteService.ts b/server/api/src/services/defaultNoteService.ts index d651e6e8..68dd3548 100644 --- a/server/api/src/services/defaultNoteService.ts +++ b/server/api/src/services/defaultNoteService.ts @@ -20,7 +20,7 @@ import { and, eq, sql } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { notes, users } from "../schema/index.js"; import type { Note } from "../schema/index.js"; -import type { DbOrTx } from "../lib/welcomePageService.js"; +import type { DbOrTx } from "../types/dbOrTx.js"; /** * デフォルトノートのタイトルを `のノート` の形式で返す。 diff --git a/server/api/src/services/indexBuilder.ts b/server/api/src/services/indexBuilder.ts index 75c2e979..d7ce00c2 100644 --- a/server/api/src/services/indexBuilder.ts +++ b/server/api/src/services/indexBuilder.ts @@ -16,6 +16,7 @@ import { and, asc, eq, isNull } from "drizzle-orm"; import { pages } from "../schema/pages.js"; import { pageContents } from "../schema/pageContents.js"; import type { Database } from "../types/index.js"; +import { ensureDefaultNote } from "./defaultNoteService.js"; /** * A single page entry as it appears in the index. @@ -290,10 +291,12 @@ export async function rebuildIndexForOwner( // try/catch alone cannot recover without an explicit SAVEPOINT). // 並行再構築で SELECT を両方通過した場合、生の一意制約違反は tx を失敗状態に // するため、ON CONFLICT DO NOTHING + 再 SELECT で勝者行を採用する。 + const defaultNote = await ensureDefaultNote(tx, ownerId); const inserted = await tx .insert(pages) .values({ ownerId, + noteId: defaultNote.id, title: INDEX_PAGE_TITLE, specialKind: "__index__", createdAt: now, diff --git a/server/api/src/services/pageAccessService.ts b/server/api/src/services/pageAccessService.ts index a101d151..6fdd907f 100644 --- a/server/api/src/services/pageAccessService.ts +++ b/server/api/src/services/pageAccessService.ts @@ -3,19 +3,17 @@ * Shared page access authorization service. */ import { HTTPException } from "hono/http-exception"; -import { eq, and, sql } from "drizzle-orm"; -import { pages, users, notes, notePages, noteMembers } from "../schema/index.js"; +import { eq, and } from "drizzle-orm"; +import { pages, users } from "../schema/index.js"; import type { Database } from "../types/index.js"; import { getNoteRole, canEdit } from "../routes/notes/helpers.js"; /** - * ページの種別と所有情報。`noteId` が非 null の場合はノートネイティブページ - * (`pages.note_id` がそのノートを指している)。 + * ページの種別と所有情報。Issue #823 以降 `noteId` は常に非 null。 * - * Page kind and ownership info. `noteId !== null` means a note-native page - * whose `pages.note_id` references that note. See issue #713. + * Page kind and ownership info. After issue #823 `noteId` is always set. */ -type PageOwnership = { id: string; ownerId: string; noteId: string | null }; +type PageOwnership = { id: string; ownerId: string; noteId: string }; async function getPageOwnership(db: Database, pageId: string): Promise { const page = await db @@ -26,7 +24,7 @@ async function getPageOwnership(db: Database, pageId: string): Promise { @@ -44,23 +42,15 @@ async function getUserEmailLowercase(db: Database, userId: string): Promise { const pageRow = await getPageOwnership(db, pageId); - if (pageRow.noteId) { - const userEmail = await getUserEmailLowercase(db, userId); - const { role } = await getNoteRole(pageRow.noteId, userId, userEmail, db); - if (!role) throw new HTTPException(403, { message: "Forbidden" }); - return; - } - - // 個人ページ:オーナーは常にアクセス可 - // Personal page: owner always has access - if (pageRow.ownerId === userId) return; - const userEmail = await getUserEmailLowercase(db, userId); - - // ページが属するノートを取得し、そのノートのメンバーかチェック - // Find notes this page belongs to and verify user is a member - const noteMembership = await db - .select({ noteId: notePages.noteId }) - .from(notePages) - .innerJoin(notes, and(eq(notes.id, notePages.noteId), eq(notes.isDeleted, false))) - .innerJoin( - noteMembers, - and( - eq(noteMembers.noteId, notePages.noteId), - // 大文字小文字を区別せずに突合する。`getUserEmailLowercase` で正規化済み - // のメールに対し、DB 側でも `LOWER(...)` を適用して旧来データや手動挿入 - // で大文字混じりの行を取りこぼさない。`helpers.ts` の `getNoteRole` と - // 同じ慣用に揃える。 - // - // Match case-insensitively. `getUserEmailLowercase` already lower-cases - // the input; apply `LOWER(...)` on the column too so legacy or manually - // inserted mixed-case rows still match. Mirrors `getNoteRole` in - // `helpers.ts`. - sql`LOWER(${noteMembers.memberEmail}) = ${userEmail}`, - eq(noteMembers.isDeleted, false), - eq(noteMembers.status, "accepted"), - ), - ) - .where(and(eq(notePages.pageId, pageId), eq(notePages.isDeleted, false))) - .limit(1); - - if (noteMembership[0]) return; - - // ノートオーナーは通常 `note_members` 行を持たないため、linked personal page でも - // `note_pages -> notes.owner_id` 経路で閲覧を許可する。Issue #713 / PR #719 review. - // Note owners usually do not have a `note_members` row. Allow linked personal - // pages through the `note_pages -> notes.owner_id` path too. - const noteOwnership = await db - .select({ noteId: notePages.noteId }) - .from(notePages) - .innerJoin( - notes, - and(eq(notes.id, notePages.noteId), eq(notes.ownerId, userId), eq(notes.isDeleted, false)), - ) - .where(and(eq(notePages.pageId, pageId), eq(notePages.isDeleted, false))) - .limit(1); - - if (noteOwnership[0]) return; - - throw new HTTPException(403, { message: "Forbidden" }); + const { role } = await getNoteRole(pageRow.noteId, userId, userEmail, db); + if (!role) throw new HTTPException(403, { message: "Forbidden" }); } /** * ページへの編集権限を確認する。 * - * - 個人ページ (`pages.note_id IS NULL`): 所有者本人のみ - * - ノートネイティブページ (`pages.note_id IS NOT NULL`): そのノートに対する - * ロールと `note.editPermission` を `canEdit` で評価 - * - * Verify the user can edit the page. + * 所属ノートに対するロールと `note.editPermission` を `canEdit` で評価する。 * - * - Personal page (`pages.note_id IS NULL`): owner only - * - Note-native page (`pages.note_id IS NOT NULL`): role on that note must - * pass `canEdit(role, note)` (owner / editor with note permissions / public - * guest under `any_logged_in` rules) + * Verify the user can edit via `canEdit(role, note)` on the owning note. * - * See issue #713. + * See issue #823. */ export async function assertPageEditAccess( db: Database, @@ -153,17 +80,10 @@ export async function assertPageEditAccess( ): Promise { const pageRow = await getPageOwnership(db, pageId); - if (pageRow.noteId) { - const userEmail = await getUserEmailLowercase(db, userId); - const { role, note } = await getNoteRole(pageRow.noteId, userId, userEmail, db); - if (!note) throw new HTTPException(404, { message: "Note not found" }); - if (!role || !canEdit(role, note)) { - throw new HTTPException(403, { message: "Forbidden" }); - } - return; - } - - if (pageRow.ownerId !== userId) { + const userEmail = await getUserEmailLowercase(db, userId); + const { role, note } = await getNoteRole(pageRow.noteId, userId, userEmail, db); + if (!note) throw new HTTPException(404, { message: "Note not found" }); + if (!role || !canEdit(role, note)) { throw new HTTPException(403, { message: "Forbidden" }); } } diff --git a/server/api/src/services/titleRenamePropagationService.ts b/server/api/src/services/titleRenamePropagationService.ts index ef2c4314..4a8b7197 100644 --- a/server/api/src/services/titleRenamePropagationService.ts +++ b/server/api/src/services/titleRenamePropagationService.ts @@ -42,7 +42,7 @@ */ import * as Y from "yjs"; -import { and, eq, sql, ne, inArray, isNull } from "drizzle-orm"; +import { and, eq, sql, ne, inArray } from "drizzle-orm"; import { links, ghostLinks, pageContents, pages } from "../schema/index.js"; import type { Database } from "../types/index.js"; import { rewriteTitleRefsInDoc, type RewriteResult } from "./ydocRenameRewrite.js"; @@ -197,27 +197,11 @@ async function rewriteSourcePage( /** * 新タイトルと一致するゴーストリンクを、リネーム対象と同一スコープ内でのみ - * 実体リンクへ昇格させる。スコープはリネーム対象の `pages.note_id` と - * `pages.owner_id` から決定する: + * 実体リンクへ昇格させる。スコープはリネーム対象の `pages.note_id` で決定する。 + * ソースページも同一 `note_id` の場合のみ昇格する(Issue #823)。 * - * - リネーム対象がノートネイティブ (`note_id` 非 NULL): ソースの `note_id` - * が同一の場合のみ昇格。 - * - リネーム対象が個人ページ (`note_id` NULL): ソースも個人 (`note_id` NULL) - * かつオーナーが同一の場合のみ昇格。 - * - * これにより、別テナント/別ノートで同一テキストを持つ `ghost_links` が - * 誤って消費されることを防ぐ(PR #736 P1 レビュー参照)。 - * - * Promote ghost-link rows matching the new title — but only within the - * renamed page's ownership / note scope. Without this filter, a rename in - * one tenant would silently consume unrelated ghost rows elsewhere that - * happen to share the same text, creating cross-tenant link edges - * (reviewed as P1 on PR #736). - * - * - Note-native target (`note_id != null`): only promote ghosts whose - * source page is in the same `note_id`. - * - Personal target (`note_id = null`): only promote ghosts whose source - * is also personal (`note_id = null`) and has the same `owner_id`. + * Promote ghost-link rows matching the new title only when the source page + * shares the renamed page's `note_id` (issue #823). */ async function promoteGhostLinks( db: Database, @@ -235,13 +219,7 @@ async function promoteGhostLinks( const scope = scopeRows[0]; if (!scope) return 0; - // 2. 同一スコープかつテキスト一致のゴースト行を列挙する。 - // Find in-scope ghost rows whose text matches the new title. - const scopePredicate = - scope.noteId !== null - ? eq(pages.noteId, scope.noteId) - : and(isNull(pages.noteId), eq(pages.ownerId, scope.ownerId)); - + // 2. 同一ノート(`pages.note_id`)かつテキスト一致のゴースト行を列挙する。 const candidates = await tx .select({ sourcePageId: ghostLinks.sourcePageId, @@ -253,7 +231,7 @@ async function promoteGhostLinks( and( sql`LOWER(TRIM(${ghostLinks.linkText})) = LOWER(TRIM(${newTitle}))`, ne(ghostLinks.sourcePageId, renamedPageId), - scopePredicate, + eq(pages.noteId, scope.noteId), ), ); diff --git a/server/api/src/types/dbOrTx.ts b/server/api/src/types/dbOrTx.ts new file mode 100644 index 00000000..ddd101ac --- /dev/null +++ b/server/api/src/types/dbOrTx.ts @@ -0,0 +1,8 @@ +import type { Database } from "./index.js"; + +/** + * Drizzle の DB またはトランザクション引数。サービスを route 側トランザクションに参加させるため。 + * + * Drizzle database handle or transaction callback argument — lets services join a route-level tx. + */ +export type DbOrTx = Parameters[0]>[0] | Database;