diff --git a/server/api/drizzle/0022_add_default_note.sql b/server/api/drizzle/0022_add_default_note.sql new file mode 100644 index 00000000..314cb5f9 --- /dev/null +++ b/server/api/drizzle/0022_add_default_note.sql @@ -0,0 +1,68 @@ +-- 0022: Default note (additive). Add `notes.is_default` and create one default +-- note per existing user titled `'のノート'`. 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. +-- +-- 0022: デフォルトノート(追加のみ)。`notes.is_default` カラムを追加し、 +-- 既存ユーザー全員に「のノート」というタイトルのデフォルトノートを +-- 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 +-- `'のノート'`. visibility/edit_permission default to private/ +-- owner_only, matching a fresh personal space. `is_default = true`. +-- +-- 既存ユーザーごとにデフォルトノートを 1 件作成する。タイトルは +-- `'のノート'`。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 +); diff --git a/server/api/drizzle/meta/_journal.json b/server/api/drizzle/meta/_journal.json index 487ec807..b334f78a 100644 --- a/server/api/drizzle/meta/_journal.json +++ b/server/api/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1777680000000, "tag": "0021_add_pages_thumbnail_object_id", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1778544000000, + "tag": "0022_add_default_note", + "breakpoints": true } ] } diff --git a/server/api/src/__tests__/routes/notes/crud.test.ts b/server/api/src/__tests__/routes/notes/crud.test.ts index df0ba94f..dedf1192 100644 --- a/server/api/src/__tests__/routes/notes/crud.test.ts +++ b/server/api/src/__tests__/routes/notes/crud.test.ts @@ -301,6 +301,33 @@ describe("DELETE /api/notes/:noteId", () => { expect(res.status).toBe(404); }); + + it("should return 400 when trying to delete a default note", async () => { + // デフォルトノート(マイノート)はユーザーの個人スペースなので削除拒否。 + // 拒否時には soft-delete UPDATE が走らないことも併せて検証し、 + // 「拒否したのに更新は適用された」の取りこぼしを防ぐ。 + // The default note is the user's personal space; deletion is rejected. + // Also assert no `update` chain fires so we lock down the contract that + // a rejected delete leaves the row untouched. + const defaultNote = createMockNote({ isDefault: true }); + const { app, chains } = createTestApp([ + [defaultNote], // requireNoteOwner → findActiveNoteById (owner) + ]); + + const res = await app.request(`/api/notes/${defaultNote.id}`, { + method: "DELETE", + headers: authHeaders(), + }); + + expect(res.status).toBe(400); + // HTTPException は text/plain でメッセージを返すため res.text() で検証する。 + // HTTPException returns the message as text/plain, so assert via res.text(). + const body = await res.text(); + expect(body).toMatch(/default note/i); + // soft-delete UPDATE が走っていないことを確認する。 + // Verify the soft-delete UPDATE chain did not execute. + expect(chains.some((c) => c.startMethod === "update")).toBe(false); + }); }); // ── GET /api/notes/:noteId ────────────────────────────────────────────────── diff --git a/server/api/src/__tests__/routes/notes/me.test.ts b/server/api/src/__tests__/routes/notes/me.test.ts new file mode 100644 index 00000000..0e1e7fcf --- /dev/null +++ b/server/api/src/__tests__/routes/notes/me.test.ts @@ -0,0 +1,94 @@ +/** + * 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, 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, 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 は走らない。ハンドラはその行をそのまま返す。 + // When a default note exists, ensureDefaultNote's SELECT returns it, no + // INSERT or extra round-trip is needed, and the handler returns it as-is. + const defaultNote = createMockNote({ + id: "note-default-001", + title: "テストユーザーのノート", + isDefault: true, + }); + + const { app } = createTestApp([ + [defaultNote], // ensureDefaultNote → getDefaultNoteOrNull + ]); + + const res = await app.request("/api/notes/me", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + 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、`returning()` で全カラムが返る。 + // First-time path: no default note yet → ensureDefaultNote reads + // users.name and INSERTs RETURNING the full row. + const newDefault = createMockNote({ + id: "note-default-new", + title: "山田のノート", + isDefault: true, + }); + + const { app } = createTestApp([ + [], // ensureDefaultNote → getDefaultNoteOrNull (none) + [{ name: "山田" }], // ensureDefaultNote → users select + [newDefault], // ensureDefaultNote → INSERT returning full row + ]); + + const res = await app.request("/api/notes/me", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + 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); + }); +}); diff --git a/server/api/src/__tests__/routes/notes/setup.ts b/server/api/src/__tests__/routes/notes/setup.ts index 8f3ba69b..27695ee3 100644 --- a/server/api/src/__tests__/routes/notes/setup.ts +++ b/server/api/src/__tests__/routes/notes/setup.ts @@ -30,6 +30,7 @@ export function createMockNote(overrides: Record = {}) { visibility: "private", editPermission: "owner_only", isOfficial: false, + isDefault: false, viewCount: 0, createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), diff --git a/server/api/src/lib/welcomePageService.ts b/server/api/src/lib/welcomePageService.ts index 249fe0bf..6bfb81de 100644 --- a/server/api/src/lib/welcomePageService.ts +++ b/server/api/src/lib/welcomePageService.ts @@ -167,10 +167,21 @@ export async function insertWelcomePage( const ydoc = prosemirrorJSONToYDoc(welcomePageSchema, doc, YDOC_FRAGMENT); const ydocState = Y.encodeStateAsUpdate(ydoc); - // 部分ユニーク index と同じ述語 (`kind='welcome' AND is_deleted=false`) を - // ON CONFLICT に指定する。並行リクエストで衝突すると INSERT は 0 行返す。 - // Match the partial unique index predicate so a concurrent insert is a - // no-op instead of aborting the transaction. + // 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. const inserted = await tx .insert(pages) .values({ diff --git a/server/api/src/routes/notes/crud.ts b/server/api/src/routes/notes/crud.ts index ae27b110..bb9d3795 100644 --- a/server/api/src/routes/notes/crud.ts +++ b/server/api/src/routes/notes/crud.ts @@ -197,7 +197,17 @@ app.delete("/:noteId", authRequired, async (c) => { const userId = c.get("userId"); const db = c.get("db"); - await requireNoteOwner(db, noteId, userId); + const note = await requireNoteOwner(db, noteId, userId); + + // デフォルトノート(マイノート)は削除不可。誤操作で個人スペースが消えるのを + // 防ぐ。再作成は `ensureDefaultNote` で可能だがリンク・履歴は失われるため + // 拒否する。Issue: 「ホーム廃止 → /notes/me 着地」スレッド参照。 + // The default note ("マイノート") is non-deletable — losing it would destroy + // the user's personal space. `ensureDefaultNote` could re-create one, but + // links and history would be gone, so we reject deletion outright. + if (note.isDefault) { + throw new HTTPException(400, { message: "Default note cannot be deleted" }); + } await db .update(notes) diff --git a/server/api/src/routes/notes/helpers.ts b/server/api/src/routes/notes/helpers.ts index 5978b7e3..05635206 100644 --- a/server/api/src/routes/notes/helpers.ts +++ b/server/api/src/routes/notes/helpers.ts @@ -31,6 +31,7 @@ export function noteRowToApi(note: Note): NoteApiFields { visibility: note.visibility, edit_permission: note.editPermission, is_official: note.isOfficial, + is_default: note.isDefault, view_count: note.viewCount, created_at: note.createdAt, updated_at: note.updatedAt, diff --git a/server/api/src/routes/notes/index.ts b/server/api/src/routes/notes/index.ts index c8759812..7c246d0b 100644 --- a/server/api/src/routes/notes/index.ts +++ b/server/api/src/routes/notes/index.ts @@ -3,6 +3,7 @@ */ import { Hono } from "hono"; import type { AppEnv } from "../../types/index.js"; +import meRoutes from "./me.js"; import crudRoutes from "./crud.js"; import pageRoutes from "./pages.js"; import memberRoutes from "./members.js"; @@ -12,6 +13,9 @@ import searchRoutes from "./search.js"; const app = new Hono(); +// `me` を先にマウントして `/:noteId` のパラメータ捕捉より優先させる。 +// Mount `me` first so `/me` resolves before `/:noteId` in `crud.ts`. +app.route("/", meRoutes); app.route("/", crudRoutes); app.route("/", pageRoutes); app.route("/", memberRoutes); diff --git a/server/api/src/routes/notes/me.ts b/server/api/src/routes/notes/me.ts new file mode 100644 index 00000000..659c4023 --- /dev/null +++ b/server/api/src/routes/notes/me.ts @@ -0,0 +1,29 @@ +/** + * /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 { 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(); + +app.get("/me", authRequired, async (c) => { + const userId = c.get("userId"); + const db = c.get("db"); + + const note = await ensureDefaultNote(db, userId); + return c.json(noteRowToApi(note)); +}); + +export default app; diff --git a/server/api/src/routes/notes/types.ts b/server/api/src/routes/notes/types.ts index f4786863..e509e703 100644 --- a/server/api/src/routes/notes/types.ts +++ b/server/api/src/routes/notes/types.ts @@ -39,6 +39,13 @@ export interface NoteApiFields { visibility: NoteVisibility; edit_permission: NoteEditPermission; is_official: boolean; + /** + * デフォルトノート(`のノート`)であるか。フロントは「マイノート」 + * バッジの表示や削除ボタンの抑止に使う。 + * Whether this is the user's default note (`のノート`). Clients + * use this to render the "マイノート" badge and hide the delete control. + */ + is_default: boolean; view_count: number; created_at: Date; updated_at: Date; diff --git a/server/api/src/schema/notes.ts b/server/api/src/schema/notes.ts index db74ea1e..48874398 100644 --- a/server/api/src/schema/notes.ts +++ b/server/api/src/schema/notes.ts @@ -7,15 +7,24 @@ import { integer, primaryKey, index, + uniqueIndex, unique, } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; import { users } from "./users.js"; import { pages } from "./pages.js"; -export /** +/** + * ノート(複数ページのまとまり)。 + * 各ユーザーには `is_default = true` のデフォルトノートが 1 件だけ存在する + * (旧来の「個人ページ」概念の置き換え)。タイトルは `のノート`。 * + * Note (a collection of pages). Every user owns exactly one default note + * (`is_default = true`) titled `のノート`. The default note + * replaces the previous "personal pages" concept; deletion is rejected at the + * API layer (`routes/notes/crud.ts`). */ -const notes = pgTable( +export const notes = pgTable( "notes", { id: uuid("id").primaryKey().defaultRandom(), @@ -33,6 +42,16 @@ const notes = pgTable( .default("owner_only"), isOfficial: boolean("is_official").notNull().default(false), viewCount: integer("view_count").notNull().default(0), + /** + * ユーザーごとに 1 件存在するデフォルトノート(マイノート)であることを示す。 + * 部分ユニーク index `idx_notes_unique_default_per_owner` により有効な + * デフォルトは 1 ユーザーにつき 1 件のみ。削除拒否はアプリ層で行う。 + * + * Marks the user's single default note ("マイノート"). The partial unique + * index `idx_notes_unique_default_per_owner` keeps at most one live default + * per owner; the delete guard lives in the API layer. + */ + isDefault: boolean("is_default").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), isDeleted: boolean("is_deleted").default(false).notNull(), @@ -42,16 +61,19 @@ const notes = pgTable( index("idx_notes_visibility").on(table.visibility), index("idx_notes_edit_permission").on(table.editPermission), index("idx_notes_is_official").on(table.isOfficial), + /** + * 1 ユーザーにつき有効なデフォルトノートは 1 件のみ。 + * At most one live default note per owner. + */ + uniqueIndex("idx_notes_unique_default_per_owner") + .on(table.ownerId) + .where(sql`${table.isDefault} = true AND ${table.isDeleted} = false`), ], ); -/** - * - */ +/** Select type for the notes table. / notes テーブルの SELECT 型。 */ export type Note = typeof notes.$inferSelect; -/** - * - */ +/** Insert type for the notes table. / notes テーブルの INSERT 型。 */ export type NewNote = typeof notes.$inferInsert; export /** diff --git a/server/api/src/schema/pages.ts b/server/api/src/schema/pages.ts index 51a9e2c9..8f3a4371 100644 --- a/server/api/src/schema/pages.ts +++ b/server/api/src/schema/pages.ts @@ -40,15 +40,14 @@ export const pages = pgTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), /** - * 所属ノート ID。NULL は個人ページ、値ありはそのノートに所属するノート - * ネイティブページ。ノートネイティブページは個人ホーム(`note_id IS NULL` - * フィルタ)には現れず、ノート削除時に `ON DELETE CASCADE` で一緒に消える。 + * 所属ノート ID。NULL は個人ページ(旧モデル)、値ありはそのノートに + * 所属するノートネイティブページ。デフォルトノート移行(PR 1b)後は + * NOT NULL に昇格させ、すべてのページがノート所属になる予定。 * Issue #713 を参照。 * - * Owning note ID. NULL means a personal page; a non-null value identifies - * a note-native page that lives only inside that note. Personal-home - * queries filter on `note_id IS NULL`, and note deletion cascades to - * note-native pages. See issue #713. + * 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. */ noteId: uuid("note_id").references(() => notes.id, { onDelete: "cascade" }), sourcePageId: uuid("source_page_id"), diff --git a/server/api/src/schema/relations.ts b/server/api/src/schema/relations.ts index dddd5e2e..f3adc0fa 100644 --- a/server/api/src/schema/relations.ts +++ b/server/api/src/schema/relations.ts @@ -75,6 +75,10 @@ const pagesRelations = relations(pages, ({ one, many }) => ({ fields: [pages.id], references: [pageContents.pageId], }), + note: one(notes, { + fields: [pages.noteId], + references: [notes.id], + }), notePages: many(notePages), snapshots: many(pageSnapshots), media: many(media), @@ -91,6 +95,7 @@ const notesRelations = relations(notes, ({ one, many }) => ({ fields: [notes.ownerId], references: [users.id], }), + pages: many(pages), notePages: many(notePages), noteMembers: many(noteMembers), noteInvitations: many(noteInvitations), diff --git a/server/api/src/services/defaultNoteService.test.ts b/server/api/src/services/defaultNoteService.test.ts new file mode 100644 index 00000000..31a978d2 --- /dev/null +++ b/server/api/src/services/defaultNoteService.test.ts @@ -0,0 +1,185 @@ +/** + * defaultNoteService の単体テスト。タイトル整形と冪等な保証ロジックを検証する。 + * Unit tests for defaultNoteService: title formatting and idempotent ensure. + */ +import { describe, it, expect } from "vitest"; +import { + ensureDefaultNote, + formatDefaultNoteTitle, + getDefaultNoteOrNull, +} from "./defaultNoteService.js"; + +// ── Mock DB helper (shared with other service tests) ─────────────────────── + +/** + * `queryResults[i]` が i 番目に発行されたクエリの戻り値になる、最小プロキシ DB。 + * Minimal proxy DB whose i-th query returns `queryResults[i]`. Mirrors the + * pattern in `invitationService.test.ts`. + */ +function createMockDb(queryResults: unknown[]) { + let queryIndex = 0; + return new Proxy({} as Record unknown>, { + get(_target, _prop: string) { + return (..._args: unknown[]) => { + const idx = queryIndex++; + const result = queryResults[idx]; + return makeChainProxy(result); + }; + }, + }); +} + +function makeChainProxy(result: unknown): unknown { + return new Proxy({} as Record, { + get(_target, prop: string) { + if (prop === "then") { + return (resolve?: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + } + if (prop === "catch") { + return (reject?: (e: unknown) => unknown) => Promise.resolve(result).catch(reject); + } + if (prop === "finally") { + return (fn?: () => void) => Promise.resolve(result).finally(fn); + } + return (..._args: unknown[]) => makeChainProxy(result); + }, + }); +} + +function buildNote(overrides: Record = {}) { + return { + id: "note-default-001", + ownerId: "user-1", + title: "山田のノート", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + isDefault: true, + viewCount: 0, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + isDeleted: false, + ...overrides, + }; +} + +// ── formatDefaultNoteTitle ───────────────────────────────────────────────── + +describe("formatDefaultNoteTitle", () => { + it("appends 'のノート' to the user name", () => { + expect(formatDefaultNoteTitle("山田")).toBe("山田のノート"); + }); + + it("works with English names", () => { + expect(formatDefaultNoteTitle("Alice")).toBe("Aliceのノート"); + }); + + it("preserves whitespace inside the name", () => { + // 表示名にスペースが含まれていても切り落とさず、そのまま連結する。 + // Whitespace in the display name is preserved verbatim. + expect(formatDefaultNoteTitle("Alice Bob")).toBe("Alice Bobのノート"); + }); +}); + +// ── getDefaultNoteOrNull ─────────────────────────────────────────────────── + +describe("getDefaultNoteOrNull", () => { + it("returns the row when a live default note exists", async () => { + const note = buildNote(); + const db = createMockDb([[note]]); + + const result = await getDefaultNoteOrNull(db as never, "user-1"); + + expect(result).toEqual(note); + }); + + it("returns null when the user has no default note yet", async () => { + const db = createMockDb([[]]); + + const result = await getDefaultNoteOrNull(db as never, "user-1"); + + expect(result).toBeNull(); + }); +}); + +// ── ensureDefaultNote ────────────────────────────────────────────────────── + +describe("ensureDefaultNote", () => { + it("returns the existing default note without inserting (idempotent re-call)", async () => { + // Given: 既に有効なデフォルトノート行がある。INSERT も users SELECT も走らない。 + // Existing default note → no INSERT, no users lookup. + const note = buildNote(); + const db = createMockDb([ + [note], // getDefaultNoteOrNull → existing row + ]); + + const result = await ensureDefaultNote(db as never, "user-1"); + + expect(result).toEqual(note); + }); + + it("creates and returns a new default note titled 'のノート' on first call", async () => { + // Given: デフォルトノート未作成。users.name を引いてタイトルを組み立て、 + // INSERT … RETURNING で新規行を返す。 + // First-time path: SELECT users.name → INSERT → RETURNING new row. + const created = buildNote({ id: "note-new", title: "山田のノート" }); + const db = createMockDb([ + [], // getDefaultNoteOrNull → no row + [{ name: "山田" }], // users select + [created], // INSERT returning + ]); + + const result = await ensureDefaultNote(db as never, "user-1"); + + expect(result).toEqual(created); + expect(result.title).toBe("山田のノート"); + }); + + it("recovers the winner's row when a concurrent insert wins (ON CONFLICT swallowed)", async () => { + // Given: 並行呼び出しに敗け、INSERT が 0 行返した場合は再 SELECT で勝者を読む。 + // Race-loser path: INSERT returns 0 rows → re-read the winner via + // getDefaultNoteOrNull and return it instead of throwing. + const winner = buildNote({ id: "note-winner", title: "山田のノート" }); + const db = createMockDb([ + [], // first getDefaultNoteOrNull → no row + [{ name: "山田" }], // users select + [], // INSERT returning (empty: conflict swallowed by ON CONFLICT DO NOTHING) + [winner], // second getDefaultNoteOrNull → winner's row + ]); + + const result = await ensureDefaultNote(db as never, "user-1"); + + expect(result).toEqual(winner); + }); + + it("throws 404 when the user does not exist", async () => { + // Given: users SELECT が空配列。404 を投げる。 + // Missing user → throw HTTPException 404 (matches the route-layer convention). + const db = createMockDb([ + [], // getDefaultNoteOrNull → no row + [], // users select → empty + ]); + + await expect(ensureDefaultNote(db as never, "user-missing")).rejects.toMatchObject({ + status: 404, + }); + }); + + it("throws 500 when both INSERT and the winner-readback fail to surface a row", async () => { + // Given: INSERT が 0 行で、かつ並行勝者の読み返しも空配列だった病的ケース。 + // 整合性が壊れている可能性があるため 500 で止める。 + // Pathological case: INSERT returns nothing AND the re-read also returns + // nothing. We refuse to silently succeed and surface a 500 instead. + const db = createMockDb([ + [], // getDefaultNoteOrNull → no row + [{ name: "山田" }], // users select + [], // INSERT returning empty + [], // second getDefaultNoteOrNull → still empty + ]); + + await expect(ensureDefaultNote(db as never, "user-1")).rejects.toMatchObject({ + status: 500, + }); + }); +}); diff --git a/server/api/src/services/defaultNoteService.ts b/server/api/src/services/defaultNoteService.ts new file mode 100644 index 00000000..d651e6e8 --- /dev/null +++ b/server/api/src/services/defaultNoteService.ts @@ -0,0 +1,105 @@ +/** + * デフォルトノート(マイノート)の生成・解決サービス。 + * + * Default-note service. Each user owns exactly one note with + * `notes.is_default = true` titled `のノート`. The default note + * replaces the previous "personal pages" concept; every page lives in some + * note, and a user's "personal space" is their default note. + * + * - `ensureDefaultNote`: 冪等。既に有効な行があればその行を返し、無ければ + * 作成する。並行呼び出しは partial unique index + * `idx_notes_unique_default_per_owner` により 1 件に正規化される。 + * - `getDefaultNoteOrNull`: 既存行を読み取るだけ。未作成なら null。 + * + * - `ensureDefaultNote`: idempotent. Returns the existing live default note + * row, or creates and returns a new one. Concurrent callers are bounded by + * the partial unique index `idx_notes_unique_default_per_owner`. + * - `getDefaultNoteOrNull`: read-only lookup; returns null when not created. + */ +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"; + +/** + * デフォルトノートのタイトルを `のノート` の形式で返す。 + * Format the default-note title as `のノート`. + * + * @param userName - users.name に格納された表示名 / Display name from users.name + */ +export function formatDefaultNoteTitle(userName: string): string { + return `${userName}のノート`; +} + +/** + * 指定ユーザーの有効なデフォルトノート行を返す。未作成なら null。 + * Returns the live default note row for the user, or null when not created. + */ +export async function getDefaultNoteOrNull(db: DbOrTx, userId: string): Promise { + const rows = await db + .select() + .from(notes) + .where(and(eq(notes.ownerId, userId), eq(notes.isDefault, true), eq(notes.isDeleted, false))) + .limit(1); + return rows[0] ?? null; +} + +/** + * 指定ユーザーのデフォルトノートを保証する。既存行があればその行を返し、 + * 無ければ作成して返す。タイトルは `のノート`。並行作成は partial + * unique index に依拠して 1 件に正規化される(衝突した側は勝者の行を再取得する)。 + * + * Ensure the user has a default note. Returns the existing row when present, + * otherwise creates one titled `のノート` and returns it. Concurrent + * callers race cleanly via the partial unique index — the loser re-reads the + * winner. + * + * @throws HTTPException 404 — `users.id` が存在しない場合 + */ +export async function ensureDefaultNote(db: DbOrTx, userId: string): Promise { + const existing = await getDefaultNoteOrNull(db, userId); + if (existing) return existing; + + const userRow = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + const user = userRow[0]; + if (!user) { + throw new HTTPException(404, { message: "User not found" }); + } + + const title = formatDefaultNoteTitle(user.name); + + // 明示的に部分ユニーク index `idx_notes_unique_default_per_owner` を target に + // 指定して、無関係なユニーク制約衝突を黙って飲み込まないようにする。 + // Explicitly target the partial unique index so we don't silently swallow + // unrelated unique-constraint violations. + const inserted = await db + .insert(notes) + .values({ + ownerId: userId, + title, + visibility: "private", + editPermission: "owner_only", + isDefault: true, + }) + .onConflictDoNothing({ + target: notes.ownerId, + where: sql`${notes.isDefault} = true AND ${notes.isDeleted} = false`, + }) + .returning(); + + const newRow = inserted[0]; + if (newRow) return newRow; + + // Lost the race against a concurrent ensureDefaultNote call. Read the winner. + // 並行呼び出しに敗けた場合は勝者の行を再取得する。 + const winner = await getDefaultNoteOrNull(db, userId); + if (winner) return winner; + throw new HTTPException(500, { + message: "Failed to ensure default note", + }); +}