# Mobile BFF β-full 実装計画

> **For Claude:** REQUIRED SUB-SKILL: Use `godmode:task-runner` to implement this plan task-by-task.

> **2026-04-24 追記:** `/v1/mobile/lots/:id` aggregate の hours / pricing 返却形は、本計画執筆後に下流で変更予定です。
> - `hours`: フラット配列 → `hours.windows[] { window_type, rows[] }` にネスト化
> - `date_overrides[]` を追加（特定日オーバーライド、`parking_lot_date_overrides` 参照）
> - `is_open_now` → `derived.is_open_now` / `derived.can_enter_now` / `derived.can_exit_now` / `derived.is_today_holiday` / `derived.active_override` に分解
>
> 背景となる DB 変更 (migration 4 本) とレスポンス例は [2026-04-24 ADR](2026-04-24-parking-hours-holidays-overrides.md) 参照。
> 本計画中の `operating_hours` text カラム返却・`pricing_rules` フラット配列は従来通り継続。

**Goal:** Parky BFF に `/v1/mobile/*` 名前空間を全面展開し、Flutter モバイルアプリが叩くすべての API を集約する

**Architecture:**
- `routes-manifest.ts` の `RouteCategory` に `"mobile"` を追加し、39 エントリを登録（SSoT）
- Aggregate 5本は新規ファイル（`src/routes/v1/mobile/`）で並列クエリ + KV キャッシュ
- Thin wrapper 34本は既存 handler を manifest で `/v1/mobile/*` に **再マウント**（新規ファイル不要、コード重複ゼロ）
- `notification-prefs`（short alias）のみ 1 ファイル追加
- 旧 `/v1/parking/:id/detail` に Sunset 90日を manifest の `sunsetDate` で自動付与

**Tech Stack:** Hono + `@hono/zod-openapi` + Cloudflare Workers (KV, Hyperdrive) + postgres.js + Zod

---

## 前提確認

- `src/routes-manifest.ts` の `RouteCategory` = `"public" | "admin" | "marketing" | "owner"` → `"mobile"` 追加が必要
- `registerRoutes` は manifest エントリの `handler` を同じ path に `app.route()` するだけ。同一ハンドラを複数 path に re-mount 可能
- Hono は同一 OpenAPIHono インスタンスを複数 path にマウントした場合、各 path 下の operation を OpenAPI spec に両方登録する（operationId は path prefix + method で自動付与）
- KV キャッシュは `createCache(env, log).swr(key, ttl, fn)` パターン（parking-detail.ts が参照実装）
- `withPgError()` / `listWithPagination()` / `callRpc()` / `requireUser` / `getSql()` は既存 lib を使う

---

## Task 1: RouteCategory に "mobile" を追加

**Files:**
- Modify: `src/routes-manifest.ts:162`

**Step 1: 型定義を拡張**

`src/routes-manifest.ts` の型定義行を変更:

```ts
// Before
export type RouteCategory = "public" | "admin" | "marketing" | "owner";

// After
export type RouteCategory = "public" | "admin" | "marketing" | "owner" | "mobile";
```

**Step 2: tsc 確認**

```bash
cd parky/api && npx tsc --noEmit
```
Expected: 0 errors（型だけの拡張なので既存コードへの影響なし）

**Step 3: commit**

```bash
git add src/routes-manifest.ts
git commit -m "feat(api): RouteCategory に mobile を追加"
```

---

## Task 2: Thin wrapper 34本 — manifest 再マウント

**Files:**
- Modify: `src/routes-manifest.ts`（imports + manifest array に mobile エントリ追加）

**Step 1: manifest の末尾に mobile セクションを追加**

`ROUTES_MANIFEST` 配列の末尾（owner セクション後）に追記:

```ts
// ---- MOBILE ---- /v1/mobile/* — Flutter アプリ専用 BFF namespace ---------
// Thin wrapper: 既存 handler を /v1/mobile/* に再マウント（コード重複なし）
// Aggregate endpoints は下記 Task 3-7 で追加する個別ファイルを参照

// --- me 系 ---
{ path: "/v1/mobile/me",                    handler: meRoutes,                   category: "mobile" },
{ path: "/v1/mobile/me/saved-parking-lots", handler: savedRoutes,                category: "mobile" },
{ path: "/v1/mobile/me/vehicles",           handler: meVehiclesRoutes,           category: "mobile" },
{ path: "/v1/mobile/me/search-presets",     handler: meSearchPresetsRoutes,      category: "mobile" },
{ path: "/v1/mobile/me/search-preferences", handler: meSearchPreferencesRoutes,  category: "mobile" },
{ path: "/v1/mobile/me/parking-sessions",   handler: meParkingSessionsRoutes,    category: "mobile", idempotent: true },
{ path: "/v1/mobile/me/reviews",            handler: meReviewsRoutes,            category: "mobile", idempotent: true },
{ path: "/v1/mobile/me/notifications",      handler: meNotificationsRoutes,      category: "mobile" },
{ path: "/v1/mobile/me/push-tokens",        handler: mePushTokensRoutes,         category: "mobile" },
{ path: "/v1/mobile/me",                    handler: meGamificationRoutes,       category: "mobile", description: "/exp, /badges, /badge-progress" },
{ path: "/v1/mobile/me/subscription",       handler: meSubscriptionRoutes,       category: "mobile", idempotent: true },
{ path: "/v1/mobile/me/consents",           handler: meConsentsRoutes,           category: "mobile" },
{ path: "/v1/mobile/me/device-permissions", handler: meDevicePermissionsRoutes,  category: "mobile" },
{ path: "/v1/mobile/me/data-export",        handler: meDataExportRoutes,         category: "mobile" },
{ path: "/v1/mobile/me/referrals",          handler: meReferralsRoutes,          category: "mobile" },

// --- lots / sessions ---
{ path: "/v1/mobile/lots",                  handler: parkingLotsRoutes,          category: "mobile" },
{ path: "/v1/mobile/sessions",              handler: parkingSessionsRoutes,      category: "mobile", idempotent: true },

// --- misc ---
{ path: "/v1/mobile/subscription-plans",    handler: subscriptionPlansRoutes,    category: "mobile" },
{ path: "/v1/mobile/storage",               handler: storageRoutes,              category: "mobile" },
{ path: "/v1/mobile/codes",                 handler: codesRoutes,                category: "mobile" },
{ path: "/v1/mobile/articles",              handler: articlesRoutes,             category: "mobile" },
{ path: "/v1/mobile/support",               handler: supportRoutes,              category: "mobile" },
{ path: "/v1/mobile/search",                handler: searchRoutes,               category: "mobile", description: "AI 検索専用" },
{ path: "/v1/mobile/auth",                  handler: authRoutes,                 category: "mobile", description: "preflight / config（JWT不要）" },
```

**Step 2: tsc 確認**

```bash
cd parky/api && npx tsc --noEmit
```
Expected: 0 errors

**Step 3: wrangler dev で疎通確認**

```bash
cd parky/api && npx wrangler dev
```

別ターミナルで:
```bash
curl -s http://localhost:8787/v1/mobile/codes | head -c 200
```
Expected: codes レスポンス（JSON）が返る

**Step 4: commit**

```bash
git add src/routes-manifest.ts
git commit -m "feat(api): mobile thin wrapper 24本を manifest に再マウント"
```

---

## Task 3: Sunset — 旧 /v1/parking/:id/detail の deprecated 化

**Files:**
- Modify: `src/routes-manifest.ts`（既存の `parkingDetailBffRoutes` エントリ）

**Step 1: manifest エントリに sunsetDate と stability を追加**

`routes-manifest.ts` の既存エントリ:
```ts
// Before
{ path: "/v1/parking", handler: parkingDetailBffRoutes, category: "public", description: "モバイル BFF aggregation: /v1/parking/{id}/detail" },

// After
{
  path: "/v1/parking",
  handler: parkingDetailBffRoutes,
  category: "public",
  description: "モバイル BFF aggregation: /v1/parking/{id}/detail — DEPRECATED: 2026-07-31 に停止。/v1/mobile/lots/:id に移行してください",
  stability: "deprecated",
  sunsetDate: "2026-07-31",
},
```

これにより `registerRoutes` が自動的に `Deprecation: true` / `Sunset: Thu, 31 Jul 2026 00:00:00 GMT` ヘッダを全レスポンスに付与する。

**Step 2: tsc 確認**

```bash
cd parky/api && npx tsc --noEmit
```

**Step 3: Sunset ヘッダ確認**

```bash
npx wrangler dev  # 別ターミナルで
curl -sI http://localhost:8787/v1/parking/00000000-0000-0000-0000-000000000001/detail | grep -i sunset
```
Expected: `sunset: Thu, 31 Jul 2026 00:00:00 GMT`

**Step 4: commit**

```bash
git add src/routes-manifest.ts
git commit -m "feat(api): /v1/parking/:id/detail を deprecated + sunset 2026-07-31"
```

---

## Task 4: Aggregate — GET /v1/mobile/lots/:id（lot-detail 移設）

**Files:**
- Create: `src/routes/v1/mobile/lot-detail.ts`
- Modify: `src/routes-manifest.ts`（import + エントリ追加）

`parking-detail.ts` のロジックをそのまま移植し、path を `/v1/mobile/lots` 配下に変更する。

**Step 1: ディレクトリ作成**

```bash
mkdir -p parky/api/src/routes/v1/mobile
```

**Step 2: lot-detail.ts を作成**

`src/routes/v1/mobile/lot-detail.ts`:

```ts
// GET /v1/mobile/lots/:id — 駐車場詳細 aggregate（parking-detail.ts から移設）
// lot + reviews + pricing_rules + nearby + is_open_now を 1 往復で返す。
// KV 300s SWR + Weak ETag で edge 304 対応。

import { createRoute, z } from "@hono/zod-openapi";
import { createRoutes } from "../../../lib/openapi-factory";
import type { AppBindings } from "../../../env";
import { getSql, callRpc, withPgError } from "../../../lib/db";
import { notFound, badRequest } from "../../../lib/errors";
import { UuidSchema } from "../../../schemas/common";
import { createCache } from "../../../lib/cache";
import { applyCachePolicy, buildWeakETag, withETag } from "../../../middleware/cache";

export const mobileLotDetailRoutes = createRoutes<AppBindings>();

// --- schema 定義 ---（parking-detail.ts と同一。共通化は将来 schemas/mobile.ts に）

const LotHead = z.object({
  id: z.string().uuid(),
  name: z.string(),
  address: z.string().nullable(),
  lat: z.number().nullable(),
  lng: z.number().nullable(),
  status: z.string().nullable(),
  total_spaces: z.number().int().nullable(),
  operating_hours: z.string().nullable(),
  updated_at: z.string(),
});

const ReviewItem = z.object({
  id: z.string().uuid(),
  user_id: z.string().uuid(),
  user_name: z.string().nullable(),
  rating: z.number().int(),
  comment: z.string().nullable(),
  created_at: z.string(),
});

const PricingRule = z.object({
  id: z.string().uuid(),
  category: z.string(),
  price_minor: z.number().int(),
  rule_order: z.number().int(),
});

const NearbyLot = z.object({ id: z.string().uuid(), name: z.string(), distance_m: z.number() });
const NearbySponsor = z.object({ id: z.string().uuid(), name: z.string(), distance_m: z.number() });

const LotDetailResponse = z
  .object({
    lot: LotHead,
    reviews: z.array(ReviewItem),
    pricing_rules: z.array(PricingRule),
    nearby: z.object({ lots: z.array(NearbyLot), sponsors: z.array(NearbySponsor) }),
    is_open_now: z.boolean().nullable(),
  })
  .openapi("MobileLotDetail");

// --- GET /:id ---

mobileLotDetailRoutes.openapi(
  createRoute({
    method: "get",
    path: "/:id",
    tags: ["mobile-lots"],
    summary: "駐車場詳細（mobile aggregate）",
    description: [
      "lot + reviews + pricing_rules + nearby + is_open_now を 1 往復で返す。",
      "KV 300s SWR + Weak ETag（lot.updated_at ベース）。",
      "旧 `GET /v1/parking/{id}/detail` の後継（Sunset: 2026-07-31）。",
    ].join("\n"),
    request: {
      params: z.object({ id: UuidSchema }),
      query: z.object({ radius_m: z.string().optional() }),
    },
    responses: {
      200: { content: { "application/json": { schema: LotDetailResponse } }, description: "駐車場詳細" },
      304: { description: "Not Modified" },
      400: { description: "Invalid radius_m" },
      404: { description: "Not found" },
    },
  }),
  async (c) => {
    const { id } = c.req.valid("param");
    const { radius_m } = c.req.valid("query");
    const radius = radius_m ? Number(radius_m) : 500;
    if (!Number.isFinite(radius) || radius <= 0 || radius > 5000) {
      throw badRequest("radius_m must be 1..5000");
    }

    const sql = getSql(c.env);

    type DetailHead = { id: string; updated_at: string; lat: number | null; lng: number | null };
    const heads = await withPgError(
      () => sql<DetailHead[]>`SELECT id, updated_at, lat, lng FROM public.parking_lots WHERE id = ${id} LIMIT 1`,
    );
    const head = heads[0];
    if (!head) throw notFound("parking_lot not found");

    const etag = buildWeakETag("parking_lot_detail", id, head.updated_at);

    return withETag(c, etag, async () => {
      const cache = createCache(c.env, c.var.log);
      const key = cache.key("parking_lot_detail", id, `r${radius}`);

      const body = await cache.swr(key, 300, async () => {
        return withPgError(async () => {
          const [lotRows, reviews, pricingRules] = await Promise.all([
            sql<z.infer<typeof LotHead>[]>`
              SELECT id, name, address, lat, lng, status, total_spaces, operating_hours, updated_at
                FROM public.parking_lots WHERE id = ${id} LIMIT 1`,
            sql<z.infer<typeof ReviewItem>[]>`
              SELECT id, user_id, user_name, rating, comment, created_at
                FROM public.parking_reviews
               WHERE parking_lot_id = ${id} AND status = 'approved'
               ORDER BY created_at DESC LIMIT 10`,
            sql<z.infer<typeof PricingRule>[]>`
              SELECT id, category, price_minor, rule_order
                FROM public.parking_lot_pricing_rules
               WHERE parking_lot_id = ${id} ORDER BY rule_order ASC`,
          ]);

          const lot = lotRows[0];
          if (!lot) throw notFound("parking_lot not found");

          const nearbyLots: z.infer<typeof NearbyLot>[] = [];
          const nearbySponsors: z.infer<typeof NearbySponsor>[] = [];
          let isOpenNow: boolean | null = null;

          if (lot.lat != null && lot.lng != null) {
            const [lots, sponsors, openNow] = await Promise.all([
              callRpc<z.infer<typeof NearbyLot>[]>(sql, "nearby_parking_lots", {
                p_lng: lot.lng, p_lat: lot.lat, p_radius_m: radius,
              }).catch(() => [] as z.infer<typeof NearbyLot>[]),
              callRpc<z.infer<typeof NearbySponsor>[]>(sql, "nearby_sponsors", {
                p_lng: lot.lng, p_lat: lot.lat, p_radius_m: radius,
              }).catch(() => [] as z.infer<typeof NearbySponsor>[]),
              callRpc<boolean | null>(sql, "is_parking_lot_open_now", {
                p_parking_lot_id: id,
              }).catch(() => null),
            ]);
            for (const l of lots ?? []) { if (l.id !== id) nearbyLots.push(l); }
            for (const s of sponsors ?? []) nearbySponsors.push(s);
            isOpenNow = openNow;
          }

          return {
            lot,
            reviews,
            pricing_rules: pricingRules,
            nearby: { lots: nearbyLots.slice(0, 20), sponsors: nearbySponsors.slice(0, 20) },
            is_open_now: isOpenNow,
          };
        });
      });

      applyCachePolicy(c, "parking_lot_detail");
      return c.json(body);
    });
  },
);
```

**Step 3: routes-manifest.ts に import と エントリ追加**

import 追加:
```ts
import { mobileLotDetailRoutes } from "./routes/v1/mobile/lot-detail";
```

manifest の mobile セクションに追加（thin wrappers の後):
```ts
// Aggregate: lot detail — /v1/mobile/lots/:id
// NOTE: /v1/mobile/lots（検索一覧）は parkingLotsRoutes の再マウントで提供済み。
//       /:id/reviews と /:id/calc-fee も parkingLotsRoutes に含まれる。
//       lot-detail は全フィールド aggregate なので専用ルートを上に置く。
{ path: "/v1/mobile/lots", handler: mobileLotDetailRoutes, category: "mobile", description: "/:id aggregate（lot+reviews+pricing+nearby）" },
```

> **重要**: `mobileLotDetailRoutes` を `parkingLotsRoutes`（thin wrapper）より **前** に manifest に置く。Hono はマウント順で先勝ちするため、`/:id` の具体的なマッチが `parkingLotsRoutes` の `/:id` に先に届いてしまう。manifest に2行並べる場合は aggregate を先に:
```ts
{ path: "/v1/mobile/lots", handler: mobileLotDetailRoutes, category: "mobile" }, // /:id aggregate — 先
{ path: "/v1/mobile/lots", handler: parkingLotsRoutes,      category: "mobile" }, // /（一覧）, /:id/reviews 等 — 後
```

**Step 4: tsc 確認**

```bash
cd parky/api && npx tsc --noEmit
```

**Step 5: 疎通確認**

```bash
# wrangler dev が起動中の状態で
curl -s "http://localhost:8787/v1/mobile/lots/00000000-0000-0000-0000-000000000001" | jq .lot.id
```
Expected: `"00000000-0000-0000-0000-000000000001"` もしくは 404

**Step 6: commit**

```bash
git add src/routes/v1/mobile/lot-detail.ts src/routes-manifest.ts
git commit -m "feat(api): GET /v1/mobile/lots/:id — aggregate lot-detail (BFF)"
```

---

## Task 5: Aggregate — GET /v1/mobile/boot

**Files:**
- Create: `src/routes/v1/mobile/boot.ts`
- Modify: `src/routes-manifest.ts`

Splash 画面: `getMe` + `getCodes` + `getAppConfig` を並列実行。

**Step 1: boot.ts を作成**

`src/routes/v1/mobile/boot.ts`:

```ts
// GET /v1/mobile/boot — Splash screen: me + codes + app_config を 1 往復で返す
import { createRoute, z } from "@hono/zod-openapi";
import { createRoutes } from "../../../lib/openapi-factory";
import type { AppBindings } from "../../../env";
import { requireUser } from "../../../middleware/auth";
import { getSql, withPgError } from "../../../lib/db";

export const mobileBootRoutes = createRoutes<AppBindings>();
mobileBootRoutes.use("*", requireUser);

const AppUserSchema = z.object({
  id: z.string().uuid(),
  display_name: z.string().nullable(),
  status: z.string(),
  created_at: z.string(),
}).nullable().openapi("BootAppUser");

const CodeItemSchema = z.object({
  category_id: z.string(),
  code: z.string(),
  display_label: z.string(),
  sort_order: z.number().int(),
});

const AppConfigSchema = z.record(z.string(), z.unknown()).nullable().openapi("BootAppConfig");

const BootResponseSchema = z.object({
  me: z.object({
    user_id: z.string().uuid(),
    email: z.string().nullable(),
    app_user: AppUserSchema,
  }),
  codes: z.array(CodeItemSchema),
  app_config: AppConfigSchema,
}).openapi("MobileBootResponse");

mobileBootRoutes.openapi(
  createRoute({
    method: "get",
    path: "/",
    tags: ["mobile-boot"],
    summary: "アプリ起動初期化（me + codes + app_config）",
    description: [
      "Splash 画面でこの 1 本を叩くだけでアプリの初期化に必要なデータが揃う。",
      "- `me`: ログインユーザーの基本情報（`GET /v1/me` 相当）",
      "- `codes`: コードマスター全件（`GET /v1/codes` 相当）",
      "- `app_config`: アプリ設定 JSON（`GET /v1/meta/app-config` 相当、なければ null）",
    ].join("\n"),
    responses: {
      200: { content: { "application/json": { schema: BootResponseSchema } }, description: "起動データ" },
      401: { description: "未認証" },
    },
  }),
  async (c) => {
    const userId = c.get("userId")!;
    const sql = getSql(c.env);

    const [meRows, adminRows, codesRows, configRows] = await withPgError(() =>
      Promise.all([
        sql`SELECT id, display_name, status, created_at FROM public.app_users WHERE auth_user_id = ${userId} LIMIT 1`,
        sql`SELECT id FROM admin.admins WHERE user_id = ${userId} AND status = 'active' LIMIT 1`,
        sql`SELECT category_id, code, display_label, sort_order FROM public.codes WHERE is_deleted = false AND lang = 'ja' ORDER BY category_id, sort_order`,
        sql`SELECT value FROM public.app_configs WHERE key = 'mobile' LIMIT 1`.catch(() => []),
      ])
    );

    const appUser = meRows[0] ?? null;
    const config = (configRows as Array<{ value: unknown }>)[0]?.value ?? null;

    return c.json({
      me: {
        user_id: userId,
        email: null,
        app_user: appUser
          ? {
              id: (appUser as { id: string }).id,
              display_name: (appUser as { display_name: string | null }).display_name,
              status: (appUser as { status: string }).status,
              created_at: (appUser as { created_at: string }).created_at,
            }
          : null,
      },
      codes: codesRows as z.infer<typeof CodeItemSchema>[],
      app_config: config as Record<string, unknown> | null,
    });
  },
);
```

> **注意**: `app_configs` テーブルが存在しない場合はエラー握りつぶし（`.catch(() => [])`）で null を返す。テーブル存在確認後に適切なクエリに修正すること。

**Step 2: routes-manifest.ts に追記**

```ts
import { mobileBootRoutes } from "./routes/v1/mobile/boot";
```

manifest の mobile セクション先頭:
```ts
{ path: "/v1/mobile/boot", handler: mobileBootRoutes, category: "mobile" },
```

**Step 3: tsc + 疎通確認**

```bash
cd parky/api && npx tsc --noEmit
curl -H "Authorization: Bearer <test-jwt>" http://localhost:8787/v1/mobile/boot | jq .codes[0]
```

**Step 4: commit**

```bash
git add src/routes/v1/mobile/boot.ts src/routes-manifest.ts
git commit -m "feat(api): GET /v1/mobile/boot — Splash aggregate (me+codes+config)"
```

---

## Task 6: Aggregate — GET /v1/mobile/home-feed

**Files:**
- Create: `src/routes/v1/mobile/home-feed.ts`
- Modify: `src/routes-manifest.ts`

KV 60s TTL + Weak ETag。`nearby_parking_lots` RPC + sponsors + active session を並列。

**Step 1: home-feed.ts を作成**

`src/routes/v1/mobile/home-feed.ts`:

```ts
// GET /v1/mobile/home-feed — Home map: 近隣 lots + sponsors + active session
// KV 60s SWR（lat/lng/radius の組み合わせをキャッシュ）

import { createRoute, z } from "@hono/zod-openapi";
import { createRoutes } from "../../../lib/openapi-factory";
import type { AppBindings } from "../../../env";
import { requireUser } from "../../../middleware/auth";
import { getSql, callRpc, withPgError } from "../../../lib/db";
import { badRequest } from "../../../lib/errors";
import { createCache } from "../../../lib/cache";
import { applyCachePolicy } from "../../../middleware/cache";

export const mobileHomeFeedRoutes = createRoutes<AppBindings>();
mobileHomeFeedRoutes.use("*", requireUser);

const LotItem = z.object({
  id: z.string().uuid(),
  name: z.string(),
  lat: z.number().nullable(),
  lng: z.number().nullable(),
  distance_m: z.number(),
  status: z.string().nullable(),
});

const SponsorItem = z.object({
  id: z.string().uuid(),
  name: z.string(),
  lat: z.number().nullable(),
  lng: z.number().nullable(),
  distance_m: z.number(),
});

const ActiveSession = z.object({
  id: z.string().uuid(),
  parking_lot_id: z.string().uuid(),
  started_at: z.string(),
  status: z.string(),
}).nullable();

const HomeFeedResponse = z.object({
  lots: z.array(LotItem),
  sponsors: z.array(SponsorItem),
  active_session: ActiveSession,
}).openapi("MobileHomeFeed");

mobileHomeFeedRoutes.openapi(
  createRoute({
    method: "get",
    path: "/",
    tags: ["mobile-home"],
    summary: "ホームマップ（nearby lots + sponsors + active session）",
    description: [
      "ホームマップ画面で必要なデータを 1 本で返す。",
      "- `lots`: 近隣駐車場（`nearby_parking_lots` RPC）",
      "- `sponsors`: 近隣スポンサー（`nearby_sponsors` RPC）",
      "- `active_session`: アクティブな駐車セッション（なければ null）",
      "",
      "キャッシュ: KV 60s SWR（lat/lng/radius_m の組み合わせ）",
    ].join("\n"),
    request: {
      query: z.object({
        lat: z.string(),
        lng: z.string(),
        radius_m: z.string().optional(),
      }),
    },
    responses: {
      200: { content: { "application/json": { schema: HomeFeedResponse } }, description: "ホームフィード" },
      400: { description: "Invalid lat/lng/radius_m" },
      401: { description: "未認証" },
    },
  }),
  async (c) => {
    const { lat: latStr, lng: lngStr, radius_m } = c.req.valid("query");
    const lat = Number(latStr);
    const lng = Number(lngStr);
    const radius = radius_m ? Number(radius_m) : 8000;

    if (!Number.isFinite(lat) || lat < -90 || lat > 90) throw badRequest("invalid lat");
    if (!Number.isFinite(lng) || lng < -180 || lng > 180) throw badRequest("invalid lng");
    if (!Number.isFinite(radius) || radius <= 0 || radius > 50000) throw badRequest("radius_m must be 1..50000");

    const userId = c.get("userId")!;
    const sql = getSql(c.env);

    const cache = createCache(c.env, c.var.log);
    // lat/lng を 4 桁に丸めてキャッシュキーに使う（微小移動でのキャッシュバスト防止）
    const latKey = lat.toFixed(4);
    const lngKey = lng.toFixed(4);
    const key = cache.key("mobile_home_feed", latKey, lngKey, `r${radius}`);

    const feedData = await cache.swr(key, 60, async () =>
      withPgError(async () => {
        const [lots, sponsors] = await Promise.all([
          callRpc<z.infer<typeof LotItem>[]>(sql, "nearby_parking_lots", {
            p_lat: lat, p_lng: lng, p_radius_m: radius,
          }).catch(() => [] as z.infer<typeof LotItem>[]),
          callRpc<z.infer<typeof SponsorItem>[]>(sql, "nearby_sponsors", {
            p_lat: lat, p_lng: lng, p_radius_m: radius,
          }).catch(() => [] as z.infer<typeof SponsorItem>[]),
        ]);
        return { lots: lots ?? [], sponsors: sponsors ?? [] };
      })
    );

    // active session はユーザー固有なのでキャッシュ外
    const sessionRows = await withPgError(() =>
      sql`SELECT id, parking_lot_id, started_at, status
            FROM public.parking_sessions
           WHERE user_id = (SELECT id FROM public.app_users WHERE auth_user_id = ${userId} LIMIT 1)
             AND status = 'active'
           ORDER BY started_at DESC LIMIT 1`
    );
    const activeSession = (sessionRows as Array<z.infer<typeof ActiveSession> & {}>)[0] ?? null;

    applyCachePolicy(c, "mobile_home_feed");
    return c.json({ ...feedData, active_session: activeSession });
  },
);
```

**Step 2: routes-manifest.ts に追記**

```ts
import { mobileHomeFeedRoutes } from "./routes/v1/mobile/home-feed";
```

manifest:
```ts
{ path: "/v1/mobile/home-feed", handler: mobileHomeFeedRoutes, category: "mobile" },
```

**Step 3: cache policy 追加**（`middleware/cache.ts` に `mobile_home_feed` がなければ追加）

`src/middleware/cache.ts` の `CACHE_POLICIES` オブジェクトに:
```ts
mobile_home_feed: { "s-maxage": 60, stale_while_revalidate: 120 },
```

**Step 4: tsc + commit**

```bash
cd parky/api && npx tsc --noEmit
git add src/routes/v1/mobile/home-feed.ts src/routes-manifest.ts src/middleware/cache.ts
git commit -m "feat(api): GET /v1/mobile/home-feed — map aggregate (lots+sponsors+session)"
```

---

## Task 7: Aggregate — GET /v1/mobile/profile-view

**Files:**
- Create: `src/routes/v1/mobile/profile-view.ts`
- Modify: `src/routes-manifest.ts`

`me` + `gamification` + `subscription_status` を並列。

**Step 1: profile-view.ts を作成**

`src/routes/v1/mobile/profile-view.ts`:

```ts
// GET /v1/mobile/profile-view — Profile screen aggregate
import { createRoute, z } from "@hono/zod-openapi";
import { createRoutes } from "../../../lib/openapi-factory";
import type { AppBindings } from "../../../env";
import { requireUser } from "../../../middleware/auth";
import { getSql, withPgError } from "../../../lib/db";

export const mobileProfileViewRoutes = createRoutes<AppBindings>();
mobileProfileViewRoutes.use("*", requireUser);

const ProfileViewResponse = z.object({
  user: z.object({
    id: z.string().uuid(),
    display_name: z.string().nullable(),
    status: z.string(),
    created_at: z.string(),
  }).nullable(),
  gamification: z.object({
    level: z.number().int(),
    xp: z.number().int(),
    xp_to_next: z.number().int(),
    badge_count: z.number().int(),
  }).nullable(),
  subscription_status: z.string().nullable(),
}).openapi("MobileProfileView");

mobileProfileViewRoutes.openapi(
  createRoute({
    method: "get",
    path: "/",
    tags: ["mobile-profile"],
    summary: "プロフィール画面（user + gamification + subscription）",
    description: "プロフィール画面に必要なデータを 1 往復で返す。",
    responses: {
      200: { content: { "application/json": { schema: ProfileViewResponse } }, description: "プロフィールデータ" },
      401: { description: "未認証" },
    },
  }),
  async (c) => {
    const userId = c.get("userId")!;
    const sql = getSql(c.env);

    const [userRows, gamRows, subRows] = await withPgError(() =>
      Promise.all([
        sql`SELECT id, display_name, status, created_at FROM public.app_users WHERE auth_user_id = ${userId} LIMIT 1`,
        sql`
          SELECT
            COALESCE((SELECT level FROM public.user_levels WHERE user_id = au.id ORDER BY achieved_at DESC LIMIT 1), 1) AS level,
            COALESCE((SELECT total_xp FROM public.user_exp WHERE user_id = au.id LIMIT 1), 0) AS xp,
            COALESCE((SELECT xp_required FROM public.level_definitions WHERE level = COALESCE((SELECT level FROM public.user_levels WHERE user_id = au.id ORDER BY achieved_at DESC LIMIT 1), 1) + 1 LIMIT 1), 9999) AS xp_to_next,
            (SELECT COUNT(*)::int FROM public.user_badges WHERE user_id = au.id) AS badge_count
          FROM public.app_users au
          WHERE au.auth_user_id = ${userId}
          LIMIT 1
        `,
        sql`SELECT status FROM public.user_subscriptions WHERE user_id = (SELECT id FROM public.app_users WHERE auth_user_id = ${userId} LIMIT 1) AND status = 'active' LIMIT 1`,
      ])
    );

    const user = (userRows as Array<{ id: string; display_name: string | null; status: string; created_at: string }>)[0] ?? null;
    const gam = (gamRows as Array<{ level: number; xp: number; xp_to_next: number; badge_count: number }>)[0] ?? null;
    const subStatus = (subRows as Array<{ status: string }>)[0]?.status ?? null;

    return c.json({ user, gamification: gam, subscription_status: subStatus });
  },
);
```

**Step 2: routes-manifest.ts に追記**

```ts
import { mobileProfileViewRoutes } from "./routes/v1/mobile/profile-view";
```

```ts
{ path: "/v1/mobile/profile-view", handler: mobileProfileViewRoutes, category: "mobile" },
```

**Step 3: tsc + commit**

```bash
cd parky/api && npx tsc --noEmit
git add src/routes/v1/mobile/profile-view.ts src/routes-manifest.ts
git commit -m "feat(api): GET /v1/mobile/profile-view — aggregate (user+gamification+sub)"
```

---

## Task 8: Aggregate — GET /v1/mobile/premium-view

**Files:**
- Create: `src/routes/v1/mobile/premium-view.ts`
- Modify: `src/routes-manifest.ts`

`current_subscription` + `plans` を並列。

**Step 1: premium-view.ts を作成**

`src/routes/v1/mobile/premium-view.ts`:

```ts
// GET /v1/mobile/premium-view — Premium screen: subscription + plans
import { createRoute, z } from "@hono/zod-openapi";
import { createRoutes } from "../../../lib/openapi-factory";
import type { AppBindings } from "../../../env";
import { requireUser } from "../../../middleware/auth";
import { getSql, withPgError } from "../../../lib/db";
import { SubscriptionPlanFeaturesSchema } from "../../../schemas/public";

export const mobilePremiumViewRoutes = createRoutes<AppBindings>();
mobilePremiumViewRoutes.use("*", requireUser);

const PlanSchema = z.object({
  id: z.string().uuid(),
  code: z.string(),
  name: z.string(),
  description: z.string().nullable(),
  price_minor: z.number().int(),
  currency: z.string(),
  billing_period: z.string(),
  features: SubscriptionPlanFeaturesSchema,
  sort_order: z.number().int(),
});

const SubSchema = z.object({
  id: z.string().uuid(),
  plan_code: z.string(),
  status: z.string(),
  current_period_end: z.string().nullable(),
}).nullable();

const PremiumViewResponse = z.object({
  current_subscription: SubSchema,
  plans: z.array(PlanSchema),
}).openapi("MobilePremiumView");

mobilePremiumViewRoutes.openapi(
  createRoute({
    method: "get",
    path: "/",
    tags: ["mobile-premium"],
    summary: "プレミアム画面（subscription + plans）",
    description: "プレミアム画面に必要なデータを 1 往復で返す。",
    responses: {
      200: { content: { "application/json": { schema: PremiumViewResponse } }, description: "プレミアムデータ" },
      401: { description: "未認証" },
    },
  }),
  async (c) => {
    const userId = c.get("userId")!;
    const sql = getSql(c.env);

    const [subRows, planRows] = await withPgError(() =>
      Promise.all([
        sql`
          SELECT us.id, sp.code AS plan_code, us.status, us.current_period_end
            FROM public.user_subscriptions us
            JOIN public.subscription_plans sp ON sp.id = us.plan_id
           WHERE us.user_id = (SELECT id FROM public.app_users WHERE auth_user_id = ${userId} LIMIT 1)
             AND us.status = 'active'
           ORDER BY us.created_at DESC LIMIT 1
        `,
        sql`
          SELECT id, code, name, description, price_minor, currency, billing_period, features, sort_order
            FROM public.subscription_plans
           WHERE is_deleted = false
           ORDER BY sort_order ASC
        `,
      ])
    );

    const sub = (subRows as Array<z.infer<typeof SubSchema> & {}>)[0] ?? null;
    return c.json({
      current_subscription: sub,
      plans: planRows as z.infer<typeof PlanSchema>[],
    });
  },
);
```

**Step 2: routes-manifest.ts に追記**

```ts
import { mobilePremiumViewRoutes } from "./routes/v1/mobile/premium-view";
```

```ts
{ path: "/v1/mobile/premium-view", handler: mobilePremiumViewRoutes, category: "mobile" },
```

**Step 3: tsc + commit**

```bash
cd parky/api && npx tsc --noEmit
git add src/routes/v1/mobile/premium-view.ts src/routes-manifest.ts
git commit -m "feat(api): GET /v1/mobile/premium-view — aggregate (subscription+plans)"
```

---

## Task 9: notification-prefs alias（短縮名ラッパー）

**Files:**
- Create: `src/routes/v1/mobile/notification-prefs.ts`
- Modify: `src/routes-manifest.ts`

既存 `/v1/me/notification-preferences` の `notification-prefs` 短縮エイリアス。

**Step 1: notification-prefs.ts を作成**

`src/routes/v1/mobile/notification-prefs.ts`:

```ts
// GET /v1/mobile/me/notification-prefs — alias for /v1/me/notification-preferences
// URL 短縮のみ。ロジックは meNotificationPrefsRoutes から流用。
import { createRoutes } from "../../../lib/openapi-factory";
import type { AppBindings } from "../../../env";
import { meNotificationPrefsRoutes } from "../../me-notification-preferences";

export const mobileNotificationPrefsRoutes = createRoutes<AppBindings>();
// 既存ルーターをそのままマウント（handler 共有）
mobileNotificationPrefsRoutes.route("/", meNotificationPrefsRoutes);
```

> **注意**: `meNotificationPrefsRoutes` が `routes/me-notification-preferences.ts` から export されていない場合は、ファイルを確認して正しい export 名を使うこと。`meSearchPreferencesRoutes` と混同しないよう注意。

**Step 2: routes-manifest.ts に追記**

```ts
{ path: "/v1/mobile/me/notification-prefs", handler: mobileNotificationPrefsRoutes, category: "mobile", description: "/notification-preferences の短縮エイリアス" },
```

**Step 3: tsc + commit**

```bash
cd parky/api && npx tsc --noEmit
git add src/routes/v1/mobile/notification-prefs.ts src/routes-manifest.ts
git commit -m "feat(api): /v1/mobile/me/notification-prefs エイリアス追加"
```

---

## Task 10: 全体疎通確認 + OpenAPI 検証

**Step 1: wrangler dev 起動**

```bash
cd parky/api && npx wrangler dev
```

**Step 2: 主要エンドポイントの疎通確認**

```bash
# 認証不要
curl -s http://localhost:8787/v1/mobile/codes | jq length
curl -s http://localhost:8787/v1/mobile/subscription-plans | jq .items[0].code

# lots aggregate（404 でも shape が正しければ OK）
curl -s http://localhost:8787/v1/mobile/lots/00000000-0000-0000-0000-000000000001 | jq .
```

**Step 3: OpenAPI spec に mobile エンドポイントが含まれることを確認**

```bash
curl -s http://localhost:8787/v1/openapi.json | jq '[.paths | keys[] | select(startswith("/v1/mobile"))] | length'
```
Expected: 30 以上（全 mobile エンドポイント数）

**Step 4: tsc 最終確認**

```bash
cd parky/api && npx tsc --noEmit
```
Expected: 0 errors

**Step 5: まとめ commit**

```bash
git add -A
git commit -m "feat(api): Mobile BFF β-full — /v1/mobile/* 全エンドポイント完成"
```

---

## Task 11: screen-api-map.html の更新

**Files:**
- Modify: `parky/docs/mobile-partner/screen-api-map.html`

**Step 1: 各 API 列を `/v1/mobile/*` に更新**

`screen-api-map.html` を開いて各 API エントリの URL を以下のマッピングに従って書き換える:

| Before | After |
|--------|-------|
| `GET /v1/me` | `GET /v1/mobile/boot` (splash) or `GET /v1/mobile/me` |
| `GET /v1/codes` | `GET /v1/mobile/boot` に統合 |
| `GET /v1/parking-lots` | `GET /v1/mobile/lots` |
| `GET /v1/parking/:id/detail` | `GET /v1/mobile/lots/:id` |
| `GET /v1/me/parking-sessions` | `GET /v1/mobile/me/parking-sessions` |
| `GET /v1/me/notifications` | `GET /v1/mobile/me/notifications` |
| etc. | etc. |

**Step 2: commit**

```bash
git add parky/docs/mobile-partner/screen-api-map.html
git commit -m "docs: screen-api-map を /v1/mobile/* に更新"
```

---

## Task 12: Flutter 側エンドポイント更新

**Files:**
- Modify: `parky/mobileapp/prototype/flutter/lib/data/parky_bff_client.dart`
- Modify: 各 API 呼び出し箇所

**Step 1: `ParkyBffClient` の主要メソッドのパスを `/mobile/***` に切り替え**

Aggregate を使う画面:
```dart
// Before
await _client.get('/me');
await _client.get('/codes');

// After (boot を使う)
await _client.get('/mobile/boot');
```

```dart
// Before (home 画面)
await _client.get('/parking-lots', queryParameters: {'lat': lat, 'lng': lng});

// After
await _client.get('/mobile/home-feed', queryParameters: {'lat': lat, 'lng': lng});
```

その他の一覧・詳細エンドポイント:
```dart
// Before
'/parking/:id/detail'  → After: '/mobile/lots/:id'
'/me/parking-sessions' → After: '/mobile/me/parking-sessions'
'/me/vehicles'         → After: '/mobile/me/vehicles'
// etc.
```

**Step 2: Flutter ビルド確認**

```bash
cd parky/mobileapp/prototype/flutter && flutter analyze
```
Expected: No errors

**Step 3: commit**

```bash
git add parky/mobileapp/prototype/flutter/lib/data/parky_bff_client.dart
git commit -m "feat(flutter): API パスを /v1/mobile/* に移行"
```

---

## Task 13: Dart クライアント再生成

**Step 1: openapi.json 取得**

```bash
# wrangler dev が起動中の状態で
curl -s http://localhost:8787/v1/openapi.json -o parky/api/openapi.json
```

**Step 2: Dart クライアント再生成**

```bash
cd parky/mobileapp/prototype/flutter && dart run build_runner build --delete-conflicting-outputs
```

**Step 3: commit**

```bash
git add parky/api/openapi.json
git add parky/mobileapp/prototype/flutter/lib/generated/
git commit -m "chore: openapi.json と Dart クライアント更新（mobile BFF 対応）"
```

---

## 完了チェックリスト

- [ ] `RouteCategory` に `"mobile"` 追加、tsc 0 errors
- [ ] manifest の mobile セクション全 39 エントリ（24 thin wrapper + 5 aggregate + Sunset 1）
- [ ] `GET /v1/mobile/boot` — 200 レスポンス確認
- [ ] `GET /v1/mobile/home-feed?lat=35.6&lng=139.7` — 200 レスポンス確認
- [ ] `GET /v1/mobile/profile-view` — 200 レスポンス確認
- [ ] `GET /v1/mobile/lots/:id` — 200 または 404
- [ ] `GET /v1/mobile/premium-view` — 200 レスポンス確認
- [ ] `GET /v1/parking/:id/detail` — `Sunset:` ヘッダ確認
- [ ] OpenAPI spec に `/v1/mobile/*` エンドポイント 30 本以上
- [ ] Flutter エンドポイントパス更新
- [ ] Dart クライアント再生成
- [ ] screen-api-map.html 更新
- [ ] tsc strict 0 errors（最終確認）
