diff --git a/.env.example b/.env.example index 18dee78..8004012 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= -CRON_SECRET= APP_MASTER_KEY_B64= OPENAI_API_KEY= OPENAI_MODEL=gpt-4o-mini +BETTER_AUTH_SECRET= +BETTER_AUTH_URL= +STORAGE_SIGNING_SECRET= PHOTO_BUCKET=photos EXPORT_BUCKET=exports + +# Supabase migration and temporary first-login bridge only. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +LEGACY_SUPABASE_URL= +LEGACY_SUPABASE_ANON_KEY= diff --git a/.github/workflows/deploy-cloudflare-dev.yml b/.github/workflows/deploy-cloudflare-dev.yml new file mode 100644 index 0000000..e1c08b6 --- /dev/null +++ b/.github/workflows/deploy-cloudflare-dev.yml @@ -0,0 +1,54 @@ +name: Deploy Cloudflare dev + +on: + push: + branches: + - dev + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: photo-texte-cloudflare-dev + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CLOUDFLARE_ACCOUNT_ID: 2ea670c2a6ff28e248ef084adf095e8b + DEV_URL: https://photo-texte-dev.mani1261790.workers.dev + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test -- --run + + - name: Check types + run: npx tsc --noEmit + + - name: Deploy dev Worker + run: npm run cf:deploy:dev + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Verify dev deployment + run: | + login_status=$(curl --silent --show-error --connect-timeout 10 --max-time 30 --output /dev/null --write-out '%{http_code}' "$DEV_URL/login") + auth_status=$(curl --silent --show-error --connect-timeout 10 --max-time 30 --output /dev/null --write-out '%{http_code}' "$DEV_URL/api/me") + test "$login_status" = "200" + test "$auth_status" = "403" diff --git a/.gitignore b/.gitignore index 65ef417..866f425 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .next +.open-next +.wrangler +worker-configuration.d.ts node_modules .env .env.local diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 2de2bca..55fa881 100644 --- a/README.md +++ b/README.md @@ -1,225 +1,188 @@ # PHOTO-TEXTE -PHOTO-TEXTE 作成アプリです(Next.js + Supabase + OpenAI API)。 - -## 概要 - -- **目的**: 写真ごとのフランス語下書きから、日本語意図整理→最終フランス語化→PPTX出力までを一貫処理。 -- **主な構成**: Next.js (App Router) / Supabase (Auth, Postgres, Storage) / OpenAI API(未設定時はフォールバック文生成)。 -- **データモデルの要点**: - - 現行: `entries` + `entry_photos`(複数写真) - - 互換: `entries` 単体(旧・単写真フロー) - ---- - -## システム全体フロー(精密版) - -### 1. 認証からエントリー作成まで - -```mermaid -flowchart TD - A[ユーザーアクセス] --> B{ログイン済み?} - B -- No --> C[サインアップとログイン API] - C --> C1{signupで既存ユーザー?} - C1 -- Yes --> C2[パスワードリセット送信] - C1 -- No --> C3[Auth作成と user_profiles 初期化] - C2 --> D[ダッシュボード表示] - C3 --> D - B -- Yes --> D - - D --> E[写真アップロード API] - E --> E1{レート制限内?} - E1 -- No --> X1[429系エラー] - E1 -- Yes --> E2{file存在かつ 8MB以下?} - E2 -- No --> X2[入力エラー] - E2 -- Yes --> E3[EXIF除去とサニタイズ] - E3 --> E4[Storage保存と assets 登録] - - E4 --> F{新規エントリー作成種別} - F -- 複数写真 --> G["/api/entries/multi"] - G --> G1{photo_asset_id 重複なし?} - G1 -- No --> X3[DUPLICATE_PHOTO] - G1 -- Yes --> G2{asset存在かつ所有者一致?} - G2 -- No --> X4[ASSET_NOT_FOUND または ASSET_FORBIDDEN] - G2 -- Yes --> G3[entries作成 status DRAFT_FR] - G3 --> G4{旧スキーマで photo_asset_id が NOT NULL ?} - G4 -- Yes --> G5[先頭写真で互換 insert を再試行] - G4 -- No --> G6[entry_photos を position 順に insert] - G5 --> G6 - - F -- 単写真互換 --> H["/api/entries"] - H --> H1[entries作成 status DRAFT_FR] - - G6 --> I[編集フェーズへ] - H1 --> I -``` - -### 2. 編集と最終文確定 - -```mermaid -flowchart TD - I[編集フェーズ] --> I1[下書き更新] - I1 --> I2{状態が DRAFT_FR または JP_AUTO_READY ?} - I2 -- No --> X5[ENTRY_LOCKED または 更新拒否] - I2 -- Yes --> I3[更新反映] - - I --> J[翻訳 API] - J --> J1{レート制限内?} - J1 -- No --> X1[429系エラー] - J1 -- Yes --> J2{対象存在かつ所有者一致?} - J2 -- No --> X6[ENTRY または PHOTO が見つからない] - J2 -- Yes --> J3{draft更新可能状態?} - J3 -- No --> X5 - J3 -- Yes --> J4[FR から JA へ翻訳] - J4 --> J5[JP_AUTO_READY へ遷移] - - I --> K[意図ロックとリライト API] - K --> K1{レート制限内?} - K1 -- No --> X1 - K1 -- Yes --> K2{対象存在かつ所有者一致?} - K2 -- No --> X6 - K2 -- Yes --> K3{プロフィール取得可?} - K3 -- No --> X7[PROFILE_NOT_FOUND] - - K3 --> K4{現行が JP_AUTO_READY ?} - K4 -- Yes --> K8[JA意図から FR 最終文を生成] - K4 -- No --> K5{現行が JP_INTENT_LOCKED ?} - K5 -- No --> X8[状態不正] - K5 -- Yes --> K6{final_fr 既存?} - K6 -- No --> X9[REWRITE_FAILED] - K6 -- Yes --> K7[FINAL_FR_READY へ確定] - - K8 --> K9{生成結果が空でない?} - K9 -- No --> X9 - K9 -- Yes --> K10[jp_intent と final_fr を保存] - K10 --> K11[FINAL_FR_READY へ確定] - - K7 --> L[差分表示 API] - K11 --> L - L --> L1{final_fr 存在?} - L1 -- No --> X10[FINAL_TEXT_REQUIRED] - L1 -- Yes --> L2[diff計算と CEFR 未知語ハイライト] - - I --> M[メモ API] - M --> M1[手動メモ CRUD] - M --> M2[自動メモ生成] - M2 --> M3{レート制限内?} - M3 -- No --> X1 - M3 -- Yes --> M4{final_fr が 1件以上ある?} - M4 -- No --> M5[suggestions は空配列] - M4 -- Yes --> M6[未知語抽出と学習メモ生成] -``` - -### 3. エクスポートとダウンロード - -```mermaid -flowchart TD - I[編集フェーズ] --> N[PPTX出力 API] - N --> N1{レート制限内?} - N1 -- No --> X1[429系エラー] - N1 -- Yes --> N2[runExportWorkflow] - - N2 --> N3{複数写真モード?} - N3 -- Yes --> N4{全photoで jp_auto と jp_intent と final_fr がある?} - N4 -- No --> X11[ENTRY_NOT_READY] - N4 -- Yes --> N5{全photo status が FINAL_FR_READY または EXPORTED ?} - N5 -- No --> X12[ENTRY_STATUS] - N5 -- Yes --> N8[assets解決と署名URL取得と画像読込] - - N3 -- No --> N6{entryに jp_auto と jp_intent と final_fr と photo_asset_id がある?} - N6 -- No --> X11 - N6 -- Yes --> N7{entry status が FINAL_FR_READY または EXPORTED ?} - N7 -- No --> X12 - N7 -- Yes --> N8 - - N8 --> N9{include_memos が true ?} - N9 -- Yes --> N10[SELF_NOTE のみ抽出] - N9 -- No --> N11[学習メモなし] - N10 --> N12[PPTX生成] - N11 --> N12 - - N12 --> N13[exports バケットへ保存と token_hash 登録] - N13 --> N14[状態更新] - N14 --> O[トークン付きダウンロード URL を返却] - - O --> P["/api/exports/:token/download"] - P --> P1{token_hash 一致?} - P1 -- No --> X13[EXPORT_NOT_FOUND] - P1 -- Yes --> P2{有効期限内?} - P2 -- No --> X14[EXPORT_EXPIRED] - P2 -- Yes --> P3[PPTXダウンロード返却] -``` - -### 4. 定期 keepalive - -```mermaid -flowchart TD - Q["Vercel Cron: /api/internal/supabase-keepalive"] --> Q1{Bearer CRON_SECRET 一致?} - Q1 -- No --> X15[401 UNAUTHORIZED] - Q1 -- Yes --> Q2[user_profiles を head select] - Q2 --> Q3[ok true を返却] -``` - ---- - -## ステータスマシン(業務状態) - -- 共通状態: `DRAFT_FR → JP_AUTO_READY → JP_INTENT_LOCKED → FINAL_FR_READY → EXPORTED` -- 下書き編集可: `DRAFT_FR`, `JP_AUTO_READY` のみ -- リライト可: `JP_INTENT_LOCKED` のみ(`/rewrite` ワークフロー) -- エクスポート可: - - 複数写真: すべての写真が `FINAL_FR_READY` または `EXPORTED` - - 単写真: entry が `FINAL_FR_READY` または `EXPORTED` - ---- - -## 最小セットアップ - -### 1) 環境変数 - -`.env.local` を作成し設定: +写真ごとのフランス語下書きを、日本語で意図確認しながら最終フランス語へ直し、PPTX / PDFとして出力する Next.js アプリです。 + +## 構成 + +- Next.js 16 App Router +- Cloudflare Workers(OpenNext) +- Cloudflare D1(Better Auth・プロフィール・エントリー・メモ) +- Cloudflare R2(写真・PPTX・PDF) +- Better Auth(HttpOnly Cookieセッション) +- OpenAI API(翻訳・書き直し・ヒント生成) + +Supabase は移行元としてだけ扱います。通常のAPI処理、認証、データ保存、ファイル保存には使いません。既存利用者のパスワードを安全に引き継ぐ期間だけ、初回ログイン時の本人確認先としてSupabase Authを利用できます。 + +## 主なデータフロー + +1. Better Authでログインし、Cookieセッションを発行 +2. ブラウザで写真をJPEG化し、Worker側でもEXIFなどのメタデータを除去 +3. 写真をR2、メタデータをD1へ保存 +4. 写真ごとの `DRAFT_FR → JP_AUTO_READY → JP_INTENT_LOCKED → FINAL_FR_READY → EXPORTED` をD1トリガーで検証 +5. PPTXまたは同じ16:9レイアウトのPDFを生成し、R2へ保存 + +D1にはSupabaseのRLSがないため、`lib/cloudflare/client.ts` が全ユーザー所有テーブルへ認証済みユーザーID条件を自動付与します。サービス権限のクライアントは、公開ダウンロードトークンの検証など限定したサーバー処理だけで使用します。 + +## ローカル開発 + +### 必要な環境変数 + +`.env.local` に次を設定します。 ```env -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= -CRON_SECRET= APP_MASTER_KEY_B64= OPENAI_API_KEY= OPENAI_MODEL=gpt-4o-mini +BETTER_AUTH_SECRET= +STORAGE_SIGNING_SECRET= PHOTO_BUCKET=photos EXPORT_BUCKET=exports ``` -`APP_MASTER_KEY_B64` 生成: +秘密値はそれぞれ32バイト以上のランダム値を使用してください。 ```bash openssl rand -base64 32 ``` -### 2) Supabase +Supabaseから移行するときだけ、以下も必要です。 -- プロジェクト作成 -- SQL Editor でマイグレーション適用: `supabase/migrations/202602050001_init_photo_texte.sql` +```env +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +``` -### 3) 起動 +### 起動と検証 ```bash npm install +npm run db:migrate:local npm run dev ``` -### 4) テスト +Cloudflareの実行環境で確認する場合: + +```bash +npm run cf:build +npx wrangler dev +``` + +品質チェック: ```bash +npm run cf:types npm test +npx tsc --noEmit +npm run build +npm run cf:build +``` + +## Cloudflareリソース + +`wrangler.jsonc` には次のバインディングがあります。 + +- `DB`: D1 `photo-texte` +- `CONTENT_BUCKET`: R2 `photo-texte-content` +- `ASSETS`: OpenNext静的アセット +- `WORKER_SELF_REFERENCE`: OpenNext自己参照サービス + +productionとdevは、Worker・D1・R2を共有しません。 + +| 環境 | Worker | D1 | R2 | +| --- | --- | --- | --- | +| production | `photo-texte` | `photo-texte` | `photo-texte-content` | +| dev | `photo-texte-dev` | `photo-texte-dev` | `photo-texte-dev-content` | + +GitHubに残っているVercel PreviewはNext.jsのビルド確認には使えますが、D1/R2の +Cloudflareバインディングがないため、API・ログインの動作確認先にはしません。 +devの統合確認には `https://photo-texte-dev.mani1261790.workers.dev` を使用します。 + +初回だけR2をCloudflare Dashboardで有効化し、バケットを作成します。 + +```bash +npx wrangler r2 bucket create photo-texte-content +npm run db:migrate:remote +``` + +本番へは秘密値を `wrangler secret put` で登録します。値を `wrangler.jsonc` やGitへ書かないでください。 + +```bash +npx wrangler secret put BETTER_AUTH_SECRET +npx wrangler secret put STORAGE_SIGNING_SECRET +npx wrangler secret put APP_MASTER_KEY_B64 +npx wrangler secret put OPENAI_API_KEY +``` + +固定ドメインを使用する場合は `BETTER_AUTH_URL` も設定します。Workersの `*.workers.dev` とlocalhostは動的ホスト検証に対応しています。 + +## Supabaseからのデータ移行 + +移行スクリプトは、認証ユーザーIDを維持したままD1へデータを入れ、写真のSHA-256を検証してR2へコピーします。期限切れの一時エクスポートは移行せず、必要時に再生成します。 + +まず件数だけ確認します。 + +```bash +npm run data:migrate:cloudflare +``` + +D1だけを移行: + +```bash +npm run data:migrate:cloudflare -- --database-only --apply +``` + +R2だけを移行: + +```bash +npm run data:migrate:cloudflare -- --objects-only --apply +``` + +dev Workerも本番と同じD1/R2を参照します。dev専用データへの二重移行は行いません。共有D1へマイグレーションを適用する場合は、影響範囲を確認して明示的に実行します。 + +```bash +npm run db:migrate:shared +``` + +全体を移行: + +```bash +npm run data:migrate:cloudflare -- --apply +``` + +### 既存利用者のパスワード + +Supabaseの管理APIからパスワードハッシュは取得できません。このため、既存利用者の初回ログインだけ次の処理を行います。 + +1. Better Authでログインを試す +2. Credential未作成ならSupabase Authで同じメール・パスワードを確認 +3. 成功時にBetter Auth形式でパスワードをハッシュし、D1へ保存 +4. 以後はCloudflareだけでログイン + +移行期間中はCloudflareへ `LEGACY_SUPABASE_URL` と `LEGACY_SUPABASE_ANON_KEY` を登録します。9利用者全員にCredentialが作成されたことを確認した後、この2値とSupabaseプロジェクトを削除できます。 + +```sql +SELECT COUNT(DISTINCT userId) +FROM account +WHERE providerId = 'credential'; +``` + +## デプロイ + +```bash +npm run cf:deploy +``` + +devへは専用環境を指定します。 + +```bash +npm run cf:deploy:dev ``` ---- +`dev` ブランチへのpushは `.github/workflows/deploy-cloudflare-dev.yml` が検証、 +D1マイグレーション、dev Workerデプロイ、公開URLの応答確認まで自動実行します。 +GitHub ActionsのRepository Secret `CLOUDFLARE_API_TOKEN` が必要です。 -## デプロイ要点(Vercel) +デプロイ前に、D1マイグレーション、R2コピー、4つの本番秘密値、必要なら2つの旧Supabaseログイン値が揃っていることを確認してください。 -- GitHub連携で `Next.js` として Import -- 上記環境変数を Vercel Project に登録 -- Supabase `Authentication > URL Configuration` に本番URLを設定 -- `vercel.json` の Cron で毎日 keepalive 実行(`CRON_SECRET` 必須) +旧 `supabase/migrations` は移行元スキーマの記録として残しています。新しい変更は `cloudflare/migrations` に追加します。 diff --git a/app/api/assets/photo/route.ts b/app/api/assets/photo/route.ts index e8a16b7..bb29725 100644 --- a/app/api/assets/photo/route.ts +++ b/app/api/assets/photo/route.ts @@ -7,7 +7,7 @@ import { handleApiError, ok } from '@/lib/api/response'; import { sanitizePhoto } from '@/lib/image/sanitize'; import { assertRateLimit } from '@/lib/rate-limit/memory'; import { photoBucket } from '@/lib/storage/buckets'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..e510518 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from 'next/server'; + +import { createAuth } from '@/lib/auth/better-auth'; + +async function handler(request: NextRequest): Promise { + const auth = await createAuth(); + return auth.handler(request); +} + +export const GET = handler; +export const POST = handler; +export const PATCH = handler; +export const PUT = handler; +export const DELETE = handler; diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 939e7b6..81f153a 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -2,29 +2,40 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; -import { handleApiError, ok } from '@/lib/api/response'; +import { handleApiError } from '@/lib/api/response'; import { loginSchema } from '@/lib/api/schemas'; -import { createAnonClient } from '@/lib/supabase/client'; +import { createAuth } from '@/lib/auth/better-auth'; +import { migrateLegacyPassword } from '@/lib/auth/legacy-supabase'; export async function POST(req: NextRequest) { try { const payload = await parseJson(req, loginSchema); - const anon = createAnonClient(); - - const result = await anon.auth.signInWithPassword({ - email: payload.email, - password: payload.password + const auth = await createAuth(); + const signIn = () => auth.api.signInEmail({ + body: { + email: payload.email, + password: payload.password + }, + headers: req.headers, + asResponse: true }); + let response = await signIn(); + + if (!response.ok && await migrateLegacyPassword(payload.email, payload.password)) { + response = await signIn(); + } - if (result.error || !result.data.user || !result.data.session) { + if (!response.ok) { badRequest('LOGIN_FAILED', 'Unable to authenticate'); } - return ok({ - user_id: result.data.user.id, - access_token: result.data.session.access_token, - refresh_token: result.data.session.refresh_token - }); + const result = await response.json() as { user?: { id?: string } }; + const headers = new Headers(response.headers); + headers.set('content-type', 'application/json; charset=utf-8'); + return new Response(JSON.stringify({ + user_id: result.user?.id ?? null, + authenticated: true + }), { status: 200, headers }); } catch (error) { return handleApiError(error); } diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index dbf53fa..48229c8 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -2,96 +2,74 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; -import { handleApiError, ok } from '@/lib/api/response'; +import { handleApiError } from '@/lib/api/response'; import { signupSchema } from '@/lib/api/schemas'; import { encryptField, generateDataKey, wrapDataKey } from '@/lib/crypto/envelope'; -import { createAnonClient, createServiceClient } from '@/lib/supabase/client'; +import { createAuth } from '@/lib/auth/better-auth'; +import { createServiceClient } from '@/lib/cloudflare/client'; +import { getAppEnv } from '@/lib/cloudflare/context'; export async function POST(req: NextRequest) { try { const payload = await parseJson(req, signupSchema); - const anon = createAnonClient(); - - const signUpResult = await anon.auth.signUp({ - email: payload.email, - password: payload.password + const auth = await createAuth(); + const response = await auth.api.signUpEmail({ + body: { + email: payload.email, + password: payload.password, + name: payload.display_name?.trim() || payload.email.split('@')[0] + }, + headers: req.headers, + asResponse: true }); - const signUpErrorCode = signUpResult.error?.code?.toLowerCase() ?? ''; - const signUpErrorMessage = signUpResult.error?.message?.toLowerCase() ?? ''; - const isAlreadyRegistered = - signUpErrorCode === 'user_already_exists' || - signUpErrorCode === 'email_exists' || - signUpErrorMessage.includes('already registered') || - signUpErrorMessage.includes('already exists'); - - if (isAlreadyRegistered) { - const resetResult = await anon.auth.resetPasswordForEmail(payload.email); - if (resetResult.error) { - badRequest('PASSWORD_RESET_FAILED', 'Unable to send password reset email'); - } - - return ok({ - user_id: null, - access_token: null, - refresh_token: null, - email_confirmation_required: true, - password_reset_requested: true - }); - } - - if (signUpResult.error || !signUpResult.data.user) { + if (!response.ok) { badRequest('SIGNUP_FAILED', 'Unable to create account'); } - const user = signUpResult.data.user; - const session = signUpResult.data.session ?? null; - const dataKey = generateDataKey(); - const wrappedDataKey = wrapDataKey(dataKey); - const emailEncrypted = encryptField(dataKey, payload.email); + const signUpResult = await response.json() as { user?: { id?: string } }; + const userId = signUpResult.user?.id; + if (!userId) badRequest('SIGNUP_FAILED', 'Unable to create account'); - const service = createServiceClient(); - const baseProfilePayload = { - id: user.id, - email_encrypted: emailEncrypted, - wrapped_data_key: wrappedDataKey, - display_name: payload.display_name ?? null, - grammatical_gender: payload.grammatical_gender, - cefr_level: payload.cefr_level, - politeness_pref: payload.politeness_pref ?? null - }; + try { + const dataKey = generateDataKey(); + const wrappedDataKey = wrapDataKey(dataKey); + const emailEncrypted = encryptField(dataKey, payload.email); - // NOTE: - // - Newer schema has `service_language`, older instances may not. - // - `upsert` keeps signup idempotent when user_profiles row already exists. - let { error: profileError } = await service.from('user_profiles').upsert( - { - ...baseProfilePayload, - service_language: payload.service_language - }, - { onConflict: 'id' } - ); + const service = await createServiceClient(); + const baseProfilePayload = { + id: userId, + email_encrypted: emailEncrypted, + wrapped_data_key: wrappedDataKey, + display_name: payload.display_name ?? null, + grammatical_gender: payload.grammatical_gender, + cefr_level: payload.cefr_level, + politeness_pref: payload.politeness_pref ?? null + }; - if ( - profileError && - /service_language/i.test(profileError.message) && - /(column|schema cache)/i.test(profileError.message) - ) { - ({ error: profileError } = await service - .from('user_profiles') - .upsert(baseProfilePayload, { onConflict: 'id' })); - } + const { error: profileError } = await service.from('user_profiles').insert( + { + ...baseProfilePayload, + service_language: payload.service_language + } + ); - if (profileError) { - badRequest('PROFILE_CREATE_FAILED', 'Unable to initialize profile'); + if (profileError) { + badRequest('PROFILE_CREATE_FAILED', 'Unable to initialize profile'); + } + } catch (error) { + const env = await getAppEnv(); + await env.DB.prepare('DELETE FROM "user" WHERE id = ?').bind(userId).run(); + throw error; } - return ok({ - user_id: user.id, - access_token: session?.access_token ?? null, - refresh_token: session?.refresh_token ?? null, - email_confirmation_required: session == null - }, 201); + const headers = new Headers(response.headers); + headers.set('content-type', 'application/json; charset=utf-8'); + return new Response(JSON.stringify({ + user_id: userId, + authenticated: true, + email_confirmation_required: false + }), { status: 201, headers }); } catch (error) { return handleApiError(error); } diff --git a/app/api/entries/[id]/diff/route.ts b/app/api/entries/[id]/diff/route.ts index 2852a5e..65bb2b8 100644 --- a/app/api/entries/[id]/diff/route.ts +++ b/app/api/entries/[id]/diff/route.ts @@ -2,14 +2,8 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { handleApiError, ok } from '@/lib/api/response'; -import { highlightUnknownWords } from '@/lib/cefr/vocab'; import { computeReadOnlyDiff } from '@/lib/diff/read-only'; -import { buildLearnerContextFromTexts } from '@/lib/learning/context'; -import { - buildLearningHighlightsWithAI, - normalizeLearningHighlights, -} from '@/lib/learning/highlight'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; export async function GET( req: NextRequest, @@ -18,54 +12,24 @@ export async function GET( try { const { id } = await context.params; const { user, client } = await authedClient(req); - const refresh = req.nextUrl.searchParams.get("refresh") === "1"; - - const [{ data: entry, error: entryError }, { data: profile, error: profileError }, { data: learnerPhotos, error: learnerPhotosError }] = - await Promise.all([ - client.from('entries').select('*').eq('id', id).single(), - client.from('user_profiles').select('cefr_level').eq('id', user.id).single(), - client - .from('entry_photos') - .select('draft_fr,final_fr') - .eq('user_id', user.id) - .order('updated_at', { ascending: false }) - .limit(30) - ]); + const { data: entry, error: entryError } = await client + .from('entries') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .single(); if (entryError || !entry) { badRequest('ENTRY_NOT_FOUND', 'Entry not found'); } - if (profileError || !profile) { - badRequest('PROFILE_NOT_FOUND', 'Profile not found'); - } - if (learnerPhotosError) { - badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch learner context'); - } if (!entry.final_fr) { badRequest('FINAL_TEXT_REQUIRED', 'Final French text not generated yet'); } const diff = computeReadOnlyDiff(entry.draft_fr, entry.final_fr); - const learnerContext = buildLearnerContextFromTexts([ - ...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? '', row.final_fr ?? '']), - entry.draft_fr ?? '', - entry.final_fr ?? '', - ]); - const learningHighlights = - (!refresh ? normalizeLearningHighlights(entry.learning_highlights) : null) ?? - await buildLearningHighlightsWithAI( - entry.draft_fr ?? '', - entry.final_fr ?? '', - profile.cefr_level, - learnerContext, - ); - return ok({ entry_id: entry.id, - diff, - learning_highlights: learningHighlights, - draft_highlights: highlightUnknownWords(entry.draft_fr, profile.cefr_level), - final_highlights: highlightUnknownWords(entry.final_fr, profile.cefr_level) + diff }); } catch (error) { return handleApiError(error); diff --git a/app/api/entries/[id]/export/pdf/route.ts b/app/api/entries/[id]/export/pdf/route.ts new file mode 100644 index 0000000..04badbb --- /dev/null +++ b/app/api/entries/[id]/export/pdf/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from 'next/server'; + +import { parseJson } from '@/lib/api/parse'; +import { handleApiError, ok } from '@/lib/api/response'; +import { exportSchema } from '@/lib/api/schemas'; +import { assertRateLimit } from '@/lib/rate-limit/memory'; +import { authedClient } from '@/lib/cloudflare/authed'; +import { runExportWorkflow } from '@/lib/workflows/export'; + +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const { user, client } = await authedClient(req); + assertRateLimit(user.id, 'pdf_export', 12, 60_000); + const payload = await parseJson(req, exportSchema); + + const result = await runExportWorkflow({ + client, + userId: user.id, + entryId: id, + includeMemos: Boolean(payload.include_memos), + format: 'pdf' + }); + + return ok(result); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/entries/[id]/export/pptx/route.ts b/app/api/entries/[id]/export/pptx/route.ts index a2f9e00..a2adf62 100644 --- a/app/api/entries/[id]/export/pptx/route.ts +++ b/app/api/entries/[id]/export/pptx/route.ts @@ -4,7 +4,7 @@ import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; import { exportSchema } from '@/lib/api/schemas'; import { assertRateLimit } from '@/lib/rate-limit/memory'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; import { runExportWorkflow } from '@/lib/workflows/export'; export async function POST( @@ -22,7 +22,8 @@ export async function POST( client, userId: user.id, entryId: id, - includeMemos: Boolean(payload.include_memos) + includeMemos: Boolean(payload.include_memos), + format: 'pptx' }); return ok(result); diff --git a/app/api/entries/[id]/lock_intent/route.ts b/app/api/entries/[id]/lock_intent/route.ts index 7b935f9..f4aa708 100644 --- a/app/api/entries/[id]/lock_intent/route.ts +++ b/app/api/entries/[id]/lock_intent/route.ts @@ -7,7 +7,7 @@ import { handleApiError, ok } from '@/lib/api/response'; import { lockIntentSchema } from '@/lib/api/schemas'; import { assertIntentLockable } from '@/lib/entries/state'; import { assertRateLimit } from '@/lib/rate-limit/memory'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; export async function POST( req: NextRequest, diff --git a/app/api/entries/[id]/memos/auto/route.ts b/app/api/entries/[id]/memos/auto/route.ts index b40f91a..ce733a5 100644 --- a/app/api/entries/[id]/memos/auto/route.ts +++ b/app/api/entries/[id]/memos/auto/route.ts @@ -3,14 +3,8 @@ import { NextRequest } from 'next/server'; import { generateLearningNotes } from '@/lib/ai/client'; import { badRequest } from '@/lib/api/errors'; import { handleApiError, ok } from '@/lib/api/response'; -import { buildLearnerContextFromTexts } from '@/lib/learning/context'; -import { - buildLearningHighlightsFromDiff, - buildLearningHighlightsWithAI, - normalizeLearningHighlights, -} from '@/lib/learning/highlight'; import { assertRateLimit } from '@/lib/rate-limit/memory'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; export async function GET( req: NextRequest, @@ -24,21 +18,14 @@ export async function GET( const [ { data: entry, error: entryError }, { data: photos, error: photosError }, - { data: learnerPhotos, error: learnerPhotosError }, { data: profile, error: profileError } ] = await Promise.all([ client.from('entries').select('*').eq('id', entryId).single(), client .from('entry_photos') - .select('draft_fr,final_fr,learning_highlights') + .select('draft_fr,final_fr') .eq('entry_id', entryId) .order('position', { ascending: true }), - client - .from('entry_photos') - .select('draft_fr,final_fr') - .eq('user_id', user.id) - .order('updated_at', { ascending: false }) - .limit(30), client .from('user_profiles') .select('cefr_level,grammatical_gender,politeness_pref,service_language') @@ -52,86 +39,29 @@ export async function GET( if (photosError) { badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch entry photos'); } - if (learnerPhotosError) { - badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch learner context'); - } if (profileError || !profile) { badRequest('PROFILE_NOT_FOUND', 'Profile not found'); } - const learnerContext = buildLearnerContextFromTexts([ - ...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? '', row.final_fr ?? '']), - entry.draft_fr ?? '', - entry.final_fr ?? '', - ]); - const pairs = photos && photos.length - ? await Promise.all(photos + ? photos .filter((p) => (p.final_fr ?? '').trim()) - .map(async (p) => { - const baseHighlights = - normalizeLearningHighlights(p.learning_highlights) ?? - await buildLearningHighlightsWithAI( - p.draft_fr ?? '', - p.final_fr ?? '', - profile.cefr_level, - learnerContext, - ); - - const highlights = buildLearningHighlightsFromDiff( - p.draft_fr ?? '', - p.final_fr ?? '', - baseHighlights, - ); - - return { + .map((p) => ({ draftFr: p.draft_fr ?? '', finalFr: p.final_fr ?? '', - highlights: { - grammarWords: highlights.grammarWords, - knownWords: highlights.knownWords, - unknownWords: highlights.unknownWords, - }, - }; })) : entry.final_fr - ? [await (async () => { - const baseHighlights = - normalizeLearningHighlights(entry.learning_highlights) ?? - await buildLearningHighlightsWithAI( - entry.draft_fr ?? '', - entry.final_fr ?? '', - profile.cefr_level, - learnerContext, - ); - - const highlights = buildLearningHighlightsFromDiff( - entry.draft_fr ?? '', - entry.final_fr ?? '', - baseHighlights, - ); - - return { + ? [{ draftFr: entry.draft_fr ?? '', finalFr: entry.final_fr ?? '', - highlights: { - grammarWords: highlights.grammarWords, - knownWords: highlights.knownWords, - unknownWords: highlights.unknownWords, - }, - }; - })()] + }] : []; if (!pairs.length) { return ok({ suggestions: [] }); } - const unknownWords = [...new Set( - pairs.flatMap((pair) => pair.highlights?.unknownWords ?? []) - )]; - const suggestions = await generateLearningNotes( pairs, { @@ -140,8 +70,7 @@ export async function GET( politenessPref: profile.politeness_pref }, { - language: profile.service_language === 'fr' ? 'fr' : 'ja', - unknownWords + language: profile.service_language === 'fr' ? 'fr' : 'ja' } ); diff --git a/app/api/entries/[id]/memos/route.ts b/app/api/entries/[id]/memos/route.ts index 3c334c7..719ee43 100644 --- a/app/api/entries/[id]/memos/route.ts +++ b/app/api/entries/[id]/memos/route.ts @@ -4,7 +4,7 @@ import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; import { createMemoSchema } from '@/lib/api/schemas'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; export async function GET( req: NextRequest, diff --git a/app/api/entries/[id]/photos/[photoId]/diff/route.ts b/app/api/entries/[id]/photos/[photoId]/diff/route.ts index b292557..d04fa03 100644 --- a/app/api/entries/[id]/photos/[photoId]/diff/route.ts +++ b/app/api/entries/[id]/photos/[photoId]/diff/route.ts @@ -2,14 +2,8 @@ import { NextRequest } from "next/server"; import { badRequest } from "@/lib/api/errors"; import { handleApiError, ok } from "@/lib/api/response"; -import { highlightUnknownWords } from "@/lib/cefr/vocab"; import { computeReadOnlyDiff } from "@/lib/diff/read-only"; -import { buildLearnerContextFromTexts } from "@/lib/learning/context"; -import { - buildLearningHighlightsWithAI, - normalizeLearningHighlights, -} from "@/lib/learning/highlight"; -import { authedClient } from "@/lib/supabase/authed"; +import { authedClient } from "@/lib/cloudflare/authed"; export async function GET( req: NextRequest, @@ -18,31 +12,12 @@ export async function GET( try { const { id: entryId, photoId } = await context.params; const { user, client } = await authedClient(req); - const refresh = req.nextUrl.searchParams.get("refresh") === "1"; - - const [ - { data: photo, error: photoError }, - { data: profile, error: profileError }, - { data: learnerPhotos, error: learnerPhotosError }, - ] = await Promise.all([ - client - .from("entry_photos") - .select("id,entry_id,user_id,draft_fr,final_fr,learning_highlights") - .eq("id", photoId) - .eq("entry_id", entryId) - .single(), - client - .from("user_profiles") - .select("cefr_level") - .eq("id", user.id) - .single(), - client - .from("entry_photos") - .select("draft_fr,final_fr") - .eq("user_id", user.id) - .order("updated_at", { ascending: false }) - .limit(30), - ]); + const { data: photo, error: photoError } = await client + .from("entry_photos") + .select("id,entry_id,user_id,draft_fr,final_fr") + .eq("id", photoId) + .eq("entry_id", entryId) + .single(); if (photoError || !photo) { badRequest("ENTRY_PHOTO_NOT_FOUND", "Entry photo not found"); @@ -50,44 +25,15 @@ export async function GET( if (photo.user_id !== user.id) { badRequest("ENTRY_PHOTO_NOT_FOUND", "Entry photo not found"); } - if (profileError || !profile) { - badRequest("PROFILE_NOT_FOUND", "Profile not found"); - } - if (learnerPhotosError) { - badRequest("ENTRY_PHOTOS_LIST_FAILED", "Unable to fetch learner context"); - } if (!photo.final_fr) { badRequest("FINAL_TEXT_REQUIRED", "Final French text not generated yet"); } const diff = computeReadOnlyDiff(photo.draft_fr, photo.final_fr); - const learnerContext = buildLearnerContextFromTexts([ - ...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? "", row.final_fr ?? ""]), - photo.draft_fr ?? "", - photo.final_fr ?? "", - ]); - const learningHighlights = - (!refresh ? normalizeLearningHighlights(photo.learning_highlights) : null) ?? - await buildLearningHighlightsWithAI( - photo.draft_fr ?? "", - photo.final_fr ?? "", - profile.cefr_level, - learnerContext, - ); - return ok({ entry_id: entryId, photo_id: photoId, - diff, - learning_highlights: learningHighlights, - draft_highlights: highlightUnknownWords( - photo.draft_fr, - profile.cefr_level, - ), - final_highlights: highlightUnknownWords( - photo.final_fr, - profile.cefr_level, - ), + diff }); } catch (error) { return handleApiError(error); diff --git a/app/api/entries/[id]/photos/[photoId]/lock_intent/route.ts b/app/api/entries/[id]/photos/[photoId]/lock_intent/route.ts index 855478b..e1c6cda 100644 --- a/app/api/entries/[id]/photos/[photoId]/lock_intent/route.ts +++ b/app/api/entries/[id]/photos/[photoId]/lock_intent/route.ts @@ -6,7 +6,7 @@ import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; import { lockIntentSchema } from '@/lib/api/schemas'; import { assertRateLimit } from '@/lib/rate-limit/memory'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; /** * POST /api/entries/:id/photos/:photoId/lock_intent diff --git a/app/api/entries/[id]/photos/[photoId]/route.ts b/app/api/entries/[id]/photos/[photoId]/route.ts index 0c3e849..83a8206 100644 --- a/app/api/entries/[id]/photos/[photoId]/route.ts +++ b/app/api/entries/[id]/photos/[photoId]/route.ts @@ -4,7 +4,7 @@ import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; import { updateEntryPhotoSchema } from '@/lib/api/schemas'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; /** * PATCH /api/entries/:id/photos/:photoId diff --git a/app/api/entries/[id]/photos/[photoId]/translate/route.ts b/app/api/entries/[id]/photos/[photoId]/translate/route.ts index 4d6bee3..ce7d706 100644 --- a/app/api/entries/[id]/photos/[photoId]/translate/route.ts +++ b/app/api/entries/[id]/photos/[photoId]/translate/route.ts @@ -4,7 +4,7 @@ import { translateFrToJa } from '@/lib/ai/client'; import { badRequest } from '@/lib/api/errors'; import { handleApiError, ok } from '@/lib/api/response'; import { assertRateLimit } from '@/lib/rate-limit/memory'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; /** * POST /api/entries/:id/photos/:photoId/translate diff --git a/app/api/entries/[id]/photos/route.ts b/app/api/entries/[id]/photos/route.ts index 3dd26db..f38945b 100644 --- a/app/api/entries/[id]/photos/route.ts +++ b/app/api/entries/[id]/photos/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { handleApiError, ok } from '@/lib/api/response'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; /** * GET /api/entries/:id/photos diff --git a/app/api/entries/[id]/rewrite/route.ts b/app/api/entries/[id]/rewrite/route.ts index b156969..e0ae5d6 100644 --- a/app/api/entries/[id]/rewrite/route.ts +++ b/app/api/entries/[id]/rewrite/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { handleApiError, ok } from '@/lib/api/response'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; import { runRewriteWorkflow } from '@/lib/workflows/rewrite'; import { assertRateLimit } from '@/lib/rate-limit/memory'; diff --git a/app/api/entries/[id]/route.ts b/app/api/entries/[id]/route.ts index a7ba7f4..0b1639f 100644 --- a/app/api/entries/[id]/route.ts +++ b/app/api/entries/[id]/route.ts @@ -6,8 +6,8 @@ import { handleApiError, ok } from "@/lib/api/response"; import { updateEntrySchema } from "@/lib/api/schemas"; import { assertDraftMutable } from "@/lib/entries/state"; import { exportBucket, photoBucket } from "@/lib/storage/buckets"; -import { authedClient } from "@/lib/supabase/authed"; -import { createServiceClient } from "@/lib/supabase/client"; +import { authedClient } from "@/lib/cloudflare/authed"; +import { createServiceClient } from "@/lib/cloudflare/client"; export async function GET( req: NextRequest, @@ -94,7 +94,7 @@ export async function DELETE( try { const { id } = await context.params; const { client } = await authedClient(req); - const service = createServiceClient(); + const service = await createServiceClient(); // Fetch entry (legacy single-photo support) const { data: entry, error: entryError } = await client diff --git a/app/api/entries/[id]/translate/route.ts b/app/api/entries/[id]/translate/route.ts index 9e8e24c..2477550 100644 --- a/app/api/entries/[id]/translate/route.ts +++ b/app/api/entries/[id]/translate/route.ts @@ -5,7 +5,7 @@ import { handleApiError, ok } from '@/lib/api/response'; import { translateFrToJa } from '@/lib/ai/client'; import { assertDraftMutable } from '@/lib/entries/state'; import { assertRateLimit } from '@/lib/rate-limit/memory'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; export async function POST( req: NextRequest, diff --git a/app/api/entries/multi/route.ts b/app/api/entries/multi/route.ts index 7a202e1..e929616 100644 --- a/app/api/entries/multi/route.ts +++ b/app/api/entries/multi/route.ts @@ -4,7 +4,7 @@ import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; import { createMultiPhotoEntrySchema } from '@/lib/api/schemas'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; /** * POST /api/entries/multi diff --git a/app/api/entries/route.ts b/app/api/entries/route.ts index c6b4060..c555f9a 100644 --- a/app/api/entries/route.ts +++ b/app/api/entries/route.ts @@ -4,7 +4,7 @@ import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; import { createEntrySchema } from '@/lib/api/schemas'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; export async function POST(req: NextRequest) { try { diff --git a/app/api/exports/[token]/download/route.ts b/app/api/exports/[token]/download/route.ts index 34f493b..8762134 100644 --- a/app/api/exports/[token]/download/route.ts +++ b/app/api/exports/[token]/download/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { badRequest } from '@/lib/api/errors'; -import { buildPptxContentDisposition } from '@/lib/pptx/download'; import { handleApiError } from '@/lib/api/response'; +import { buildExportContentDisposition, ExportFormat } from '@/lib/exports/download'; import { hashExportToken } from '@/lib/exports/token'; import { exportBucket } from '@/lib/storage/buckets'; -import { createServiceClient } from '@/lib/supabase/client'; +import { createServiceClient } from '@/lib/cloudflare/client'; export async function GET( _req: NextRequest, @@ -13,7 +13,7 @@ export async function GET( ) { try { const { token } = await context.params; - const service = createServiceClient(); + const service = await createServiceClient(); const hash = hashExportToken(token); const { data: file, error } = await service @@ -44,12 +44,14 @@ export async function GET( : { data: null }; const arrayBuffer = await download.data.arrayBuffer(); + const format: ExportFormat = file.object_path.toLowerCase().endsWith('.pdf') ? 'pdf' : 'pptx'; return new NextResponse(Buffer.from(arrayBuffer), { status: 200, headers: { - 'Content-Type': - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'Content-Disposition': buildPptxContentDisposition(entry?.title_fr) + 'Content-Type': format === 'pdf' + ? 'application/pdf' + : 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'Content-Disposition': buildExportContentDisposition(entry?.title_fr, format) } }); } catch (error) { diff --git a/app/api/internal/supabase-keepalive/route.ts b/app/api/internal/supabase-keepalive/route.ts deleted file mode 100644 index 206524c..0000000 --- a/app/api/internal/supabase-keepalive/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -import { createServiceClient } from '@/lib/supabase/client'; - -function isAuthorized(req: NextRequest): boolean { - const secret = process.env.CRON_SECRET; - const authorization = req.headers.get('authorization'); - return Boolean(secret && authorization === `Bearer ${secret}`); -} - -export async function GET(req: NextRequest) { - if (!isAuthorized(req)) { - return NextResponse.json({ ok: false, error: 'UNAUTHORIZED' }, { status: 401 }); - } - - const client = createServiceClient(); - const { error } = await client - .from('user_profiles') - .select('id', { head: true, count: 'exact' }) - .limit(1); - - if (error) { - return NextResponse.json( - { ok: false, error: 'SUPABASE_KEEPALIVE_FAILED', detail: error.message }, - { status: 500 } - ); - } - - return NextResponse.json({ - ok: true, - source: 'supabase-keepalive', - timestamp: new Date().toISOString() - }); -} diff --git a/app/api/me/route.ts b/app/api/me/route.ts index db0d66d..648932b 100644 --- a/app/api/me/route.ts +++ b/app/api/me/route.ts @@ -3,13 +3,14 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; -import { profileUpdateSchema } from '@/lib/api/schemas'; +import { profileLanguageUpdateSchema, profileUpdateSchema } from '@/lib/api/schemas'; import { encryptField, unwrapDataKey } from '@/lib/crypto/envelope'; -import { authedClient } from '@/lib/supabase/authed'; -import { createServiceClient } from '@/lib/supabase/client'; +import { authedClient } from '@/lib/cloudflare/authed'; +import { createServiceClient } from '@/lib/cloudflare/client'; +import { getAppEnv } from '@/lib/cloudflare/context'; async function deletePrefixObjects(bucket: string, prefix: string): Promise { - const service = createServiceClient(); + const service = await createServiceClient(); const list = await service.storage.from(bucket).list(prefix, { limit: 1000, sortBy: { column: 'name', order: 'asc' } @@ -72,8 +73,24 @@ export async function PUT(req: NextRequest) { const dataKey = unwrapDataKey(profileForKey.wrapped_data_key); updateBody.email_encrypted = encryptField(dataKey, nextEmail); - const emailUpdate = await client.auth.updateUser({ email: nextEmail }); - if (emailUpdate.error) { + const env = await getAppEnv(); + try { + const existing = await env.DB + .prepare('SELECT id FROM "user" WHERE lower(email) = lower(?) AND id <> ? LIMIT 1') + .bind(nextEmail, user.id) + .first<{ id: string }>(); + if (existing) { + badRequest('PROFILE_UPDATE_FAILED', 'Unable to update profile'); + } + + const emailUpdate = await env.DB + .prepare('UPDATE "user" SET email = ?, updatedAt = ? WHERE id = ?') + .bind(nextEmail, Date.now(), user.id) + .run(); + if (!emailUpdate.success) { + badRequest('PROFILE_UPDATE_FAILED', 'Unable to update profile'); + } + } catch { badRequest('PROFILE_UPDATE_FAILED', 'Unable to update profile'); } } @@ -86,6 +103,13 @@ export async function PUT(req: NextRequest) { .single(); if (error || !data) { + if (nextEmail) { + const env = await getAppEnv(); + await env.DB + .prepare('UPDATE "user" SET email = ?, updatedAt = ? WHERE id = ?') + .bind(user.email, Date.now(), user.id) + .run(); + } badRequest('PROFILE_UPDATE_FAILED', 'Unable to update profile'); } @@ -98,16 +122,43 @@ export async function PUT(req: NextRequest) { } } +export async function PATCH(req: NextRequest) { + try { + const { user, client } = await authedClient(req); + const payload = await parseJson(req, profileLanguageUpdateSchema); + const { data, error } = await client + .from('user_profiles') + .update({ service_language: payload.service_language }) + .eq('id', user.id) + .select('service_language,updated_at') + .single(); + + if (error || !data) { + badRequest('PROFILE_UPDATE_FAILED', 'Unable to update profile language'); + } + + return ok(data); + } catch (error) { + return handleApiError(error); + } +} + export async function DELETE(req: NextRequest) { try { const { user, client } = await authedClient(req); - const service = createServiceClient(); await deletePrefixObjects(process.env.PHOTO_BUCKET ?? 'photos', user.id); await deletePrefixObjects(process.env.EXPORT_BUCKET ?? 'exports', user.id); - await client.rpc('delete_my_account'); - await service.auth.admin.deleteUser(user.id); + await client.from('exports').delete(); + await client.from('memos').delete(); + await client.from('entry_photos').delete(); + await client.from('entries').delete(); + await client.from('assets').delete(); + await client.from('user_profiles').delete(); + + const env = await getAppEnv(); + await env.DB.prepare('DELETE FROM "user" WHERE id = ?').bind(user.id).run(); return ok({ deleted: true }); } catch (error) { diff --git a/app/api/memos/[id]/route.ts b/app/api/memos/[id]/route.ts index f530771..adce04b 100644 --- a/app/api/memos/[id]/route.ts +++ b/app/api/memos/[id]/route.ts @@ -3,7 +3,7 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; -import { authedClient } from '@/lib/supabase/authed'; +import { authedClient } from '@/lib/cloudflare/authed'; import { z } from 'zod'; const updateMemoSchema = z.object({ diff --git a/app/api/storage/[bucket]/[...path]/route.ts b/app/api/storage/[bucket]/[...path]/route.ts new file mode 100644 index 0000000..69e16d5 --- /dev/null +++ b/app/api/storage/[bucket]/[...path]/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAppEnv } from '@/lib/cloudflare/context'; +import { verifyStorageSignature } from '@/lib/cloudflare/storage-signature'; + +const ALLOWED_BUCKETS = new Set(['photos', 'exports']); + +export async function GET( + request: NextRequest, + context: { params: Promise<{ bucket: string; path: string[] }> } +) { + const { bucket, path: segments } = await context.params; + const path = segments.join('/'); + const expires = Number(request.nextUrl.searchParams.get('expires')); + const signature = request.nextUrl.searchParams.get('signature') ?? ''; + + if (!ALLOWED_BUCKETS.has(bucket) || !path || path.includes('..')) { + return NextResponse.json({ error: { code: 'INVALID_PATH' } }, { status: 400 }); + } + + const env = await getAppEnv(); + if (!(await verifyStorageSignature(env, bucket, path, expires, signature))) { + return NextResponse.json({ error: { code: 'INVALID_SIGNATURE' } }, { status: 403 }); + } + + const object = await env.CONTENT_BUCKET.get(`${bucket}/${path}`); + if (!object) { + return NextResponse.json({ error: { code: 'OBJECT_NOT_FOUND' } }, { status: 404 }); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('cache-control', 'private, max-age=60'); + headers.set('x-content-type-options', 'nosniff'); + + return new Response(object.body, { headers }); +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000..bd96c03 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/apple-icon.tsx b/app/apple-icon.tsx deleted file mode 100644 index decd41f..0000000 --- a/app/apple-icon.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { ImageResponse } from 'next/og'; - -export const size = { - width: 180, - height: 180 -}; - -export const contentType = 'image/png'; - -function renderIcon(sizePx: number) { - const innerSize = Math.round(sizePx * 0.7); - const borderRadius = Math.round(sizePx * 0.25); - const innerRadius = Math.round(sizePx * 0.22); - const borderWidth = Math.max(2, Math.round(sizePx * 0.03)); - const fontSize = Math.round(sizePx * 0.28); - const letterSpacing = Math.round(sizePx * 0.015); - - return ( -
-
- PT -
-
- ); -} - -export default function AppleIcon() { - return new ImageResponse(renderIcon(size.width), { - ...size - }); -} diff --git a/app/entries/new/page.tsx b/app/entries/new/page.tsx index 6049f36..734b7c5 100644 --- a/app/entries/new/page.tsx +++ b/app/entries/new/page.tsx @@ -66,9 +66,7 @@ async function loadImageElement(file: File): Promise { } async function maybeDownscalePhoto(file: File): Promise { - if (!file.type.startsWith("image/") || file.size <= MAX_UPLOAD_BYTES) { - return file; - } + if (!file.type.startsWith("image/")) return file; const image = await loadImageElement(file); const scale = Math.min( @@ -481,7 +479,10 @@ export default function NewEntryPage() { } // 2) Create multi-photo entry + per-photo records - const created = await apiFetch<{ entry: { id: string } }>( + const created = await apiFetch<{ + entry: { id: string }; + photos: Array<{ id: string }>; + }>( "/api/entries/multi", { method: "POST", @@ -499,6 +500,18 @@ export default function NewEntryPage() { window.localStorage.removeItem(NEW_ENTRY_DRAFT_STORAGE_KEY); void clearIndexedDraft().catch(() => undefined); } + + // The creation form already contains the original photo and French text. + // Generate Japanese here so the editor opens on the next meaningful task + // instead of repeating the same information in its draft step. + await Promise.allSettled( + created.photos.map((photo) => + apiFetch(`/api/entries/${created.entry.id}/photos/${photo.id}/translate`, { + method: "POST", + body: "{}", + }), + ), + ); router.push(`/entries/${created.entry.id}`); } catch (err) { const message = (err as Error).message || ""; @@ -523,166 +536,155 @@ export default function NewEntryPage() { } return ( -
-
-

{t("新規エントリー作成", "Créer une entrée")}

-

+

+
+ {t("ステップ 1 / 6", "Étape 1 / 6")} +

{t("写真とフランス語", "Photos et texte français")}

+

{t( - "写真(最大10枚)を選び、写真ごとにフランス語テキストを書いてください。", - "Sélectionnez jusqu’à 10 photos et écrivez un texte en français pour chaque photo.", + "最大10枚の写真を選び、写真ごとにフランス語テキストを書いてください。", + "Sélectionnez jusqu’à 10 photos et écrivez un texte français pour chacune.", )}

- -
- - -
+ + +
+
+
+ 1 +

{t("写真を選ぶ", "Choisir les photos")}

+ {photos.length} / 10
- - - {photos.length > 0 ? ( -
-
- {photos.map((p, i) => ( - - ))} -
- -
-
- {activePhoto ? ( - {`photo-${activeIndex - ) : null} - -
- - - + + + {activePhoto ? ( + <> + {`photo-${activeIndex +
+ {photos.map((photo, index) => ( - -
-

- {t( - "この端末で元の写真を読み込めなくなった場合は、この写真を選び直してください。", - "Si le fichier d’origine n’est plus lisible sur cet appareil, sélectionnez de nouveau cette photo.", - )} -

-
- -
-