Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/0020_add_default_note.sql
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
);
7 changes: 7 additions & 0 deletions server/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@
"when": 1777507200000,
"tag": "0019_add_api_errors",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1778544000000,
"tag": "0020_add_default_note",
"breakpoints": true
}
]
}
20 changes: 20 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,26 @@ describe("DELETE /api/notes/:noteId", () => {

expect(res.status).toBe(404);
});

it("should return 400 when trying to delete a default note", async () => {
// デフォルトノート(マイノート)はユーザーの個人スペースなので削除拒否。
// The default note is the user's personal space; deletion is rejected.
const defaultNote = createMockNote({ isDefault: true });
const { app } = 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);
});
});

// ── GET /api/notes/:noteId ──────────────────────────────────────────────────
Expand Down
96 changes: 96 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,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);
});
});
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
12 changes: 12 additions & 0 deletions server/api/src/lib/welcomePageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -175,6 +186,7 @@ export async function insertWelcomePage(
.insert(pages)
.values({
ownerId: userId,
noteId,
title,
Comment on lines 186 to 189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Insert welcome pages into note_pages when assigning note_id

Now that insertWelcomePage creates a note-native page (note_id is set), it also needs to add a corresponding note_pages row; otherwise the page is effectively invisible in note workflows. Both GET /api/notes/:noteId/pages (server/api/src/routes/notes/pages.ts) and note search (server/api/src/routes/notes/search.ts) read visible pages from note_pages, so newly onboarded users can end up with a default note that appears empty even though the welcome page was created.

Useful? React with 👍 / 👎.

contentPreview,
kind: "welcome",
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
41 changes: 41 additions & 0 deletions server/api/src/routes/notes/me.ts
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];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a redundant database query here. ensureDefaultNote already performs a lookup (and potentially an insert) to ensure the note exists. If ensureDefaultNote is refactored to return the full Note row, this subsequent db.select() call can be removed, improving the efficiency of this frequently used endpoint.

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;
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