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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions server/api/drizzle/0022_add_default_note.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
-- 0022: 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.
--
-- 0022: デフォルトノート(追加のみ)。`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
);
7 changes: 7 additions & 0 deletions server/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
27 changes: 27 additions & 0 deletions server/api/src/__tests__/routes/notes/crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────
Expand Down
94 changes: 94 additions & 0 deletions server/api/src/__tests__/routes/notes/me.test.ts
Original file line number Diff line number Diff line change
@@ -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<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 は走らない。ハンドラはその行をそのまま返す。
// 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<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、`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<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);
});
});
1 change: 1 addition & 0 deletions server/api/src/__tests__/routes/notes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function createMockNote(overrides: Record<string, unknown> = {}) {
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"),
Expand Down
19 changes: 15 additions & 4 deletions server/api/src/lib/welcomePageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 11 additions & 1 deletion server/api/src/routes/notes/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions server/api/src/routes/notes/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions server/api/src/routes/notes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -12,6 +13,9 @@ import searchRoutes from "./search.js";

const app = new Hono<AppEnv>();

// `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);
Expand Down
29 changes: 29 additions & 0 deletions server/api/src/routes/notes/me.ts
Original file line number Diff line number Diff line change
@@ -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<AppEnv>();

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;
7 changes: 7 additions & 0 deletions server/api/src/routes/notes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export interface NoteApiFields {
visibility: NoteVisibility;
edit_permission: NoteEditPermission;
is_official: boolean;
/**
* デフォルトノート(`<users.name>のノート`)であるか。フロントは「マイノート」
* バッジの表示や削除ボタンの抑止に使う。
* Whether this is the user's default note (`<users.name>のノート`). Clients
* use this to render the "マイノート" badge and hide the delete control.
*/
is_default: boolean;
view_count: number;
created_at: Date;
updated_at: Date;
Expand Down
Loading
Loading