-
Notifications
You must be signed in to change notification settings - Fork 0
feat(api): add default note ("マイノート") foundation #821
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
5c5f971
059ff50
74ff3a9
0d48ae0
924501b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| -- 0020: Default note (additive). Add `notes.is_default` and create one default | ||
| -- note per existing user titled `'<users.name>のノート'`. This is the additive | ||
| -- foundation for the "every page belongs to a note" model — a follow-up | ||
| -- migration will backfill personal pages into the default note, drop the | ||
| -- `note_pages` link table, and promote `pages.note_id` to NOT NULL. | ||
| -- | ||
| -- 0020: デフォルトノート(追加のみ)。`notes.is_default` カラムを追加し、 | ||
| -- 既存ユーザー全員に「<users.name>のノート」というタイトルのデフォルトノートを | ||
| -- 1 件ずつ作成する。これは「すべてのページはノートに属する」モデルへの土台で、 | ||
| -- 既存個人ページをデフォルトノートに移行し `note_pages` を廃止する破壊的変更は | ||
| -- 後続マイグレーションで行う。 | ||
| -- | ||
| -- IF NOT EXISTS / re-run safety: | ||
| -- 既に手動適用された開発環境でも壊れないよう、`ADD COLUMN IF NOT EXISTS`, | ||
| -- `CREATE INDEX IF NOT EXISTS`, `WHERE NOT EXISTS` を使う。 | ||
| -- Use IF NOT EXISTS / NOT EXISTS guards so re-running on dev DBs that | ||
| -- already partially applied this migration manually is safe. | ||
|
|
||
| -- ── notes.is_default ──────────────────────────────────────────────────────── | ||
| -- | ||
| -- Boolean flag. Exactly one row per user has `is_default = true` (enforced by | ||
| -- the partial unique index further down). Default notes are not deletable | ||
| -- (enforced in the API layer — see `routes/notes/crud.ts`). | ||
| -- | ||
| -- ユーザー 1 人につき有効なデフォルトノートは 1 件のみ(部分ユニーク index で | ||
| -- 担保)。削除拒否はアプリケーション層(`routes/notes/crud.ts`)で行う。 | ||
|
|
||
| ALTER TABLE "notes" | ||
| ADD COLUMN IF NOT EXISTS "is_default" boolean NOT NULL DEFAULT false; | ||
| --> statement-breakpoint | ||
|
|
||
| -- ── Partial unique index: at most one default note per owner ─────────────── | ||
| -- | ||
| -- Prevents a user from ending up with two active default notes through any | ||
| -- code path (auto-creation race, manual SQL, etc.). Soft-deleted defaults | ||
| -- (`is_deleted = true`) are excluded from the predicate so a future | ||
| -- "restore" flow can re-create one without needing to clear the flag. | ||
| -- | ||
| -- 1 ユーザーにつき有効なデフォルトノートは 1 件のみ。論理削除済みの行は | ||
| -- 述語から除外し、将来の「復元」フローで作り直せる余地を残す。 | ||
|
|
||
| CREATE UNIQUE INDEX IF NOT EXISTS "idx_notes_unique_default_per_owner" | ||
| ON "notes" ("owner_id") | ||
| WHERE "is_default" = true AND "is_deleted" = false; | ||
| --> statement-breakpoint | ||
|
|
||
| -- ── Default-note backfill ────────────────────────────────────────────────── | ||
| -- | ||
| -- Create one default note per existing user. Title follows | ||
| -- `'<users.name>のノート'`. visibility/edit_permission default to private/ | ||
| -- owner_only, matching a fresh personal space. `is_default = true`. | ||
| -- | ||
| -- 既存ユーザーごとにデフォルトノートを 1 件作成する。タイトルは | ||
| -- `'<users.name>のノート'`。visibility と edit_permission は private / | ||
| -- owner_only に揃える。 | ||
| -- | ||
| -- 既に有効なデフォルトノートを持つユーザー(`is_default = true` の行が存在) | ||
| -- はスキップする。 | ||
|
|
||
| INSERT INTO "notes" ("owner_id", "title", "visibility", "edit_permission", "is_default") | ||
| SELECT u."id", u."name" || 'のノート', 'private', 'owner_only', true | ||
| FROM "user" u | ||
| WHERE NOT EXISTS ( | ||
| SELECT 1 FROM "notes" n | ||
| WHERE n."owner_id" = u."id" | ||
| AND n."is_default" = true | ||
| AND n."is_deleted" = false | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| /** | ||
| * GET /api/notes/me — デフォルトノート(マイノート)取得エンドポイントのテスト。 | ||
| * Tests for `GET /api/notes/me`, the default-note landing endpoint. | ||
| */ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import type { Context, Next } from "hono"; | ||
| import type { AppEnv } from "../../../types/index.js"; | ||
|
|
||
| vi.mock("../../../middleware/auth.js", () => ({ | ||
| authRequired: async (c: Context<AppEnv>, next: Next) => { | ||
| const userId = c.req.header("x-test-user-id"); | ||
| if (!userId) return c.json({ message: "Unauthorized" }, 401); | ||
| c.set("userId", userId); | ||
| await next(); | ||
| }, | ||
| authOptional: async (c: Context<AppEnv>, next: Next) => { | ||
| const userId = c.req.header("x-test-user-id"); | ||
| if (userId) c.set("userId", userId); | ||
| await next(); | ||
| }, | ||
| })); | ||
|
|
||
| import { TEST_USER_ID, createMockNote, createTestApp, authHeaders } from "./setup.js"; | ||
|
|
||
| describe("GET /api/notes/me", () => { | ||
| it("returns the existing default note with snake_case fields", async () => { | ||
| // Given: ensureDefaultNote の SELECT が既存行を返す → INSERT/再 SELECT は不要、 | ||
| // /me ハンドラの SELECT で返す。 | ||
| // Given an existing default note: ensureDefaultNote's SELECT returns it, | ||
| // skipping INSERT and re-SELECT; the handler's SELECT returns it. | ||
| const defaultNote = createMockNote({ | ||
| id: "note-default-001", | ||
| title: "テストユーザーのノート", | ||
| isDefault: true, | ||
| }); | ||
|
|
||
| const { app } = createTestApp([ | ||
| [defaultNote], // ensureDefaultNote → getDefaultNoteIdOrNull | ||
| [defaultNote], // handler SELECT | ||
| ]); | ||
|
|
||
| const res = await app.request("/api/notes/me", { | ||
| method: "GET", | ||
| headers: authHeaders(), | ||
| }); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| const body = (await res.json()) as Record<string, unknown>; | ||
| expect(body.id).toBe(defaultNote.id); | ||
| expect(body.is_default).toBe(true); | ||
| expect(body.title).toBe("テストユーザーのノート"); | ||
| expect(body.owner_id).toBe(TEST_USER_ID); | ||
| expect(body.visibility).toBe("private"); | ||
| expect(body.edit_permission).toBe("owner_only"); | ||
| }); | ||
|
|
||
| it("creates the default note on first access and returns it", async () => { | ||
| // Given: 初回アクセスで `notes.is_default=true` の行は無い → users.name から | ||
| // タイトルを組み立てて INSERT → handler SELECT で読み返す。 | ||
| // First-time access path: no default note yet, so ensureDefaultNote reads | ||
| // users.name, INSERTs, then the handler SELECT reads back the new row. | ||
| const newDefault = createMockNote({ | ||
| id: "note-default-new", | ||
| title: "山田のノート", | ||
| isDefault: true, | ||
| }); | ||
|
|
||
| const { app } = createTestApp([ | ||
| [], // ensureDefaultNote → getDefaultNoteIdOrNull (none) | ||
| [{ name: "山田" }], // ensureDefaultNote → users select | ||
| [{ id: newDefault.id }], // ensureDefaultNote → INSERT returning | ||
| [newDefault], // handler SELECT | ||
| ]); | ||
|
|
||
| const res = await app.request("/api/notes/me", { | ||
| method: "GET", | ||
| headers: authHeaders(), | ||
| }); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| const body = (await res.json()) as Record<string, unknown>; | ||
| expect(body.id).toBe(newDefault.id); | ||
| expect(body.is_default).toBe(true); | ||
| expect(body.title).toBe("山田のノート"); | ||
| }); | ||
|
|
||
| it("returns 401 when not authenticated", async () => { | ||
| const { app } = createTestApp([]); | ||
|
|
||
| const res = await app.request("/api/notes/me", { | ||
| method: "GET", | ||
| }); | ||
|
|
||
| expect(res.status).toBe(401); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ 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 { ensureDefaultNote } from "../services/defaultNoteService.js"; | ||
| import { VideoServer } from "./videoServerExtension.js"; | ||
| import { | ||
| welcomePageContent, | ||
|
|
@@ -167,6 +168,16 @@ export async function insertWelcomePage( | |
| const ydoc = prosemirrorJSONToYDoc(welcomePageSchema, doc, YDOC_FRAGMENT); | ||
| const ydocState = Y.encodeStateAsUpdate(ydoc); | ||
|
|
||
| // ウェルカムページはデフォルトノート(「<ユーザー名>のノート」)に所属させる。 | ||
| // 既存フローでは個人ページとして `note_id = NULL` で作っていたが、デフォルト | ||
| // ノートモデル(PR 1a 以降)では新規ページもノート所属に揃える。 | ||
| // `ensureDefaultNote` は冪等で、まだ存在しなければここで作成する。 | ||
| // Welcome pages live in the user's default note ("<users.name>のノート"). | ||
| // The previous flow stored them as personal pages (`note_id = NULL`); the | ||
| // default-note model unifies new pages under their owning note. | ||
| // `ensureDefaultNote` is idempotent and creates the note on first use. | ||
| const noteId = await ensureDefaultNote(tx, userId); | ||
|
|
||
| // 部分ユニーク index と同じ述語 (`kind='welcome' AND is_deleted=false`) を | ||
| // ON CONFLICT に指定する。並行リクエストで衝突すると INSERT は 0 行返す。 | ||
| // Match the partial unique index predicate so a concurrent insert is a | ||
|
|
@@ -175,6 +186,7 @@ export async function insertWelcomePage( | |
| .insert(pages) | ||
| .values({ | ||
| ownerId: userId, | ||
| noteId, | ||
| title, | ||
|
Comment on lines
186
to
189
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Now that Useful? React with 👍 / 👎. |
||
| contentPreview, | ||
| kind: "welcome", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| /** | ||
| * /api/notes/me — 呼び出し元のデフォルトノート(マイノート)を返す。 | ||
| * | ||
| * GET /api/notes/me | ||
| * 呼び出し元の `notes.is_default = true` の行を返す。未作成ならその場で | ||
| * `ensureDefaultNote` で作成する(idempotent)。フロントの `/notes/me` | ||
| * ランディングが最初に叩くエンドポイント。 | ||
| * | ||
| * GET /api/notes/me — return the caller's default note ("マイノート"). If one | ||
| * does not exist yet (e.g. a brand-new account), it is created on the fly. | ||
| * The frontend `/notes/me` landing page hits this endpoint first. | ||
| */ | ||
| import { Hono } from "hono"; | ||
| import { HTTPException } from "hono/http-exception"; | ||
| import { eq } from "drizzle-orm"; | ||
| import { notes } from "../../schema/index.js"; | ||
| import { authRequired } from "../../middleware/auth.js"; | ||
| import type { AppEnv } from "../../types/index.js"; | ||
| import { ensureDefaultNote } from "../../services/defaultNoteService.js"; | ||
| import { noteRowToApi } from "./helpers.js"; | ||
|
|
||
| const app = new Hono<AppEnv>(); | ||
|
|
||
| app.get("/me", authRequired, async (c) => { | ||
| const userId = c.get("userId"); | ||
| const db = c.get("db"); | ||
|
|
||
| const noteId = await ensureDefaultNote(db, userId); | ||
|
|
||
| const rows = await db.select().from(notes).where(eq(notes.id, noteId)).limit(1); | ||
| const row = rows[0]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a redundant database query here. |
||
| if (!row) { | ||
| // ensureDefaultNote が成功しているのにここで取れないのは整合性破壊。 | ||
| // ensureDefaultNote returned an id that no longer exists — invariant break. | ||
| throw new HTTPException(500, { message: "Default note vanished" }); | ||
| } | ||
|
|
||
| return c.json(noteRowToApi(row)); | ||
| }); | ||
|
|
||
| export default app; | ||
Uh oh!
There was an error while loading. Please reload this page.