ツイートを投稿して最新のツイートを閲覧できる
Translation pending
AI summary · website content
SocialTypeScript · PLpgSQLGitHub @kusumoto-tendaOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗Next.js App Router の教材をベースにした Twitter(X)風アプリ。要件は docs/functions.md、画面遷移は docs/front_design.md を参照。
| 層 | 場所 | 役割 |
|---|---|---|
| shared | shared/ | UUID正規化など業務知識を含まない処理。他の層をimportしない |
| domain | domain/ | entities/(ユーザーの型)、value_objects/(入力ルール・画像の状態)、repository_ifs/(永続化の契約)、external_service_ifs/(パスワードハッシュ・セッション終了・ストレージ・画像検査の契約)。フレームワークやDB、上位層に依存しない |
| application | application/ | usecases/<機能>/(登録・投稿などの処理)、dtos/<機能>/(一覧型・結果型)、query_service_ifs/<機能>/(タイムライン・プロフィール・お気に入りの読み取り契約)。Next.jsや具体実装に依存しない |
| infra | infra/ | persistence/postgres/(接続・マイグレーション・repositories/・query_services/)と external_services/(scrypt・Vercel Blob・sharp)。domain/applicationの契約を実装する |
| main | main/container/ | 具体実装の組み立て(DI)。Web用は機能別ファイルで server-only・遅延初期化、CLI用は cli/ に置き server-only を使わない |
| app | app/ | 画面、Server Action、Route Handler、Auth.js の設定。サーバー側の処理は main/container/ の取得関数だけを呼び、クライアントコンポーネントからは import しない |
DB は Vercel の Storage から作成する Postgres(Neon)を使う。
pnpm dlx vercel link)。POSTGRES_URL などの環境変数が自動で登録される。AUTH_SECRET(openssl rand -base64 32 の値)を追加する。AUTH_URL は Vercel 上では設定しない。pnpm install
pnpm dlx vercel env pull .env.local # POSTGRES_URL などを .env.local に保存
pnpm db:migrate # POSTGRES_URL_NON_POOLING(なければ POSTGRES_URL)に適用
pnpm dev
.env.local が .env より優先される。ローカル専用の AUTH_URL などは .env に書く(.env.example 参照)。vercel env pull で取得するDBは本番と同じ。開発で分けたい場合は Neon で開発用ブランチを作り、その接続URLを .env.development.local の POSTGRES_URL(プール経由)と POSTGRES_URL_NON_POOLING(プールなし)に書く。pnpm dev と pnpm db:migrate はこちらを優先する。twitter に作成する。pnpm db:migrate を実行する。結合テストと E2E はデータを書き込むため、本番・開発とは別の DB を TEST_POSTGRES_URL に指定する。Neon コンソールでテスト用のブランチ(または別の database)を作り、その接続URLを .env に書く。Vercel の環境変数には登録しない。
pnpm db:migrate --test で TEST_POSTGRES_URL 側にも適用できる(テストは自動で適用する)。
| コマンド | 内容 |
|---|---|
pnpm test:unit | domain・ユースケース・Server Action・UI の単体テスト(DB不要) |
pnpm test:integration | 実 PostgreSQL の Repository と実 scrypt のテスト |
pnpm test:e2e | Playwright。テストDBに向けた本番ビルドを port 3100 で起動して検証 |
pnpm typecheck | 型チェック |
TEST_POSTGRES_URL が必須。未設定、またはアプリ用の URL(Neon のプール経由・プールなしを含む)と同じ DB なら実行前に失敗する。pnpm exec playwright install chromium でブラウザを取得する。/login は登録時と同じ形式ルールでID・パスワードを検証してから認証する。形式違反はAuth.jsへの直接リクエストでも認証ユースケースで拒否する。/login は / に移動する。Cookieの有無だけでは判断しない。twitter.tweets)・フォロー(twitter.follows)・リツイート(twitter.retweets)は 002-create-timeline.sql で作成する。利用者を削除すると関連行もcascadeで削除される。(出来事の日時, 種類, ID) のキーセットで20件ずつページングする。OFFSETは使わない。timestamptz 型のパラメータを Date(ミリ秒)に変換するため、SQLでは ::text::timestamptz で渡している。test/support/timeline-fixtures.ts)。/profile/{ユーザーID} は未ログインでも閲覧できる。自分かどうかはDBの対象UUIDとセッションのUUIDで判断する。follower_id = 本人、フォロワー数は follow_id = 本人 の件数。向きごとの独立したサブクエリで数える。002 までを使う)。/profile/{ユーザーID}/edit は本人だけが使える。画面の表示時に加え、保存のたびにServer Actionでセッションと利用者を確認し、actorId === targetId のときだけ更新する(フォームの値や previousState は権限判断に使わない)。users_display_id_ci_unique の違反だけを競合として扱い、部分的な更新は起きない。revalidatePath でメイン・プロフィール・編集画面の表示を無効化し、表示用Cookie flash_profile で更新通知を出す(登録通知の flash とは別)。textarea の改行はブラウザがCRLFで送るため、自己紹介はサーバーでLFに揃えてから検証・保存する。/profile/{ユーザーID}/delete は本人だけが使える。ページ表示(GET)では削除せず、Server Actionで毎回セッションのUUIDを確認し、actorId === targetId かつ現在のパスワードが一致したときだけ削除する。DELETE FROM twitter.users WHERE id = ... の1文だけで行い、投稿・フォロー・リツイートは既存FKのON DELETE CASCADEで消える(論理削除・復元はなし)。revalidatePath で無効化し、Auth.jsの signOut({ redirect: false }) で認証Cookieを無効化してから /login へ移動する。通知は表示用Cookie flash_account(deleted / signout-failed / account-missing)で出す。BLOB_READ_WRITE_TOKEN / BLOB_STORE_ID(開発は .env.development.local)、テスト用は TEST_BLOB_READ_WRITE_TOKEN / TEST_BLOB_STORE_ID(.env)に書く。003-create-tweet-media.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する。既存データの移行は不要。CRON_SECRET を登録する。vercel.json のCronが毎日 18:00 UTC(日本時間3:00)に回収を実行する(Hobbyプランの日次制約に合わせている)。/api/tweet-images/upload で予約し、予約パス(tweet-uploads/{下書きID}/{予約ID})だけに書き込める15分のトークンを受け取って、ブラウザから Blob へ直接アップロードする。画像の本体はアプリのサーバーを通らない(Vercel Functionsの4.5MB上限を避ける)。/api/tweet-images/verify で、サーバーが実体を読み(5MBで打ち切り)、sharpでJPEG/PNGとしてデコードできること・画素数(4,000万以下)・Content-Typeとの一致を確かめる。完了webhookは使わない。/media/{画像ID} からストリームで配信する(private, no-store・nosniff)。DBで公開中の投稿に属することを毎回確認する。pnpm media:cleanup(--test でテスト用)とCronが同じ回収処理を実行する。期限切れの下書きの破棄、時期が来た削除予約の実行、管理パス配下の24時間以上古い孤立した実体の削除を行う。Blobの削除に失敗した予約は残して再試行する。sql.begin)で行う。004-edit-tweet.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する(003 の後)。投稿の版(tweets.revision)、下書きの用途(tweet_drafts.purpose = create / edit・対象の投稿・準備時の版)、画像の位置の一意制約の遅延化、削除予約の時刻の変更を含む。updated_at を進める(何も変えない保存は成功するが、版・編集日時は変わらない)。投稿日時(created_at)は変えないので、一覧の並びとカーソルは変わらない。revalidatePath で表示を更新し、成功の通知はルートlayoutのダイアログ管理(TweetComposerProvider)が出す。003・004 の適用が前提)。expectedActorId)と照合する。食い違えば誰の投稿も削除しない(Aで開いてBに切り替えた場合)。未ログインならログイン画面で「ツイートを削除するにはログインしてください」を表示する。DELETE ... WHERE id AND owner_id を1つのトランザクションで行う。画像・リツイートはCASCADE、画像の実体の削除予約は既存のトリガーが同じトランザクション内に作る。ON DELETE SET NULL は投稿のロック後に下書きを更新するため、編集の確定も、下書きから対象を読んだうえで投稿→下書きの順にロックする。下書きを先にロックする方式では、ロック後に作られた編集用の下書きでデッドロックが起き得た(順序を固定した結合テストで確認)。pnpm media:cleanup で消える。開いていた編集用の下書きは対象がNULLになり、その新規画像は下書きの破棄・期限切れで回収される(無関係な投稿・画像は残る)。revalidatePath で / と全プロフィールを更新し、同じ元投稿のカード・リツイートをすべて消す。成功の通知はルートlayoutの TweetComposerProvider が1回だけ出し、フォーカスは一覧の見出しへ移す。pnpm build 成功。削除後の pnpm media:cleanup --test は、トークンの期限前は実体を残し、期限後に削除した投稿の画像だけを回収した(残す投稿の画像は残り、再実行も成功)。005-create-favorites.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する(004 の後)。ER図のLike(相手からも見える「♡」)とFavorite(自分専用の保存)は別機能で、Favoriteは自分専用の保存としてLikeとは独立して実装した(ER図の executor_id は実装に合わせて user_id とした)。aria-pressed。押したら次の状態を明示して送り、トグルにしない。FOR KEY SHARE でロックしてから行う。保存済みへの登録は保存日時・UUIDを変えず(ON CONFLICT DO NOTHING)、解除済みへの解除も成功。解除後の再追加は新しい保存日時で先頭に出る。競合中に関連が消えた場合の外部キー違反だけを「対象なし」に変え、他のDBエラーは成功扱いしない。expectedActorId)と現在の利用者が違えば、誰の保存も変えない。失敗時は表示中の状態を変えずに理由を通知する(通知はルートlayoutの ActionFeedbackProvider)。処理中は押せない。/favorites はログイン中の本人だけの一覧(未ログインは /login、URLのuserIdは無視)。保存した日時の降順 (created_at, id) で20件ずつ、元投稿の現在の本文・画像・作者を1ページ1回のSQLで表示する。カーソルは本人のUUIDと一覧の種類に結び付け、他人のカーソルは不正として扱う。空・不正なカーソル・取得障害を区別し、解除して空になったページにも先頭へのリンクを出す。/favorites を再取得させる。pnpm build 成功。開発用DB(Neonの開発ブランチ)にも005を適用済み。app/ui/tweets/invoke-dialog-action.ts で包む。NETWORK_ERROR、クライアントだけが作るコード)へ変える。redirect()・notFound() 等のNext.jsの制御例外は導入版の公開API unstable_rethrow で同じ値のまま再throwし、投稿成功後の /・未ログイン時の /login への移動を妨げない(unstableなAPIへの依存はこのファイルだけ)。readOnly)。/ へ移動し、編集は再送として成功(版・画像・削除予約を増やさない)、削除は「このツイートは既に削除されています」として閉じて一覧を更新する。app/ui/tweets/tweet-composer.tsx)。サーバーが返した準備の失敗の理由(「このツイートは削除できません」など)は対象外で、次の準備操作まで表示する。006-create-likes.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する(005 の後)。ER図のLike(executor_id / tweet_id / created_at)のとおり、Favoriteとは別の twitter.likes に保存し、利用者・元投稿ごとに1行。aria-pressed、件数は公開(読み上げは「いいねN件」)。押したら次の状態を明示して送り、トグルにしない。FOR KEY SHARE、元投稿を FOR NO KEY UPDATE でロックして(お気に入りと同じ利用者→投稿の順)同じ元投稿への操作を直列化し、最後に件数を読み返す。登録済みへの登録はUUID・日時を変えず、解除済みへの解除も成功。解除後の再登録は新しい日時で公開一覧の先頭に出る。投稿の本文・日時・版は変えない。競合中に関連が消えた場合の外部キー違反だけを「対象なし」に変え、他のDBエラーは成功扱いしない。invokeDialogAction で同じ値のまま再throwする。処理中は押せない。?tab=likes(「いいね」タブ)は、その利用者がいいねした元投稿の公開一覧で、未ログインでも閲覧できる。いいねした日時の降順 (created_at, id) で20件ずつ、元投稿の現在の本文・画像を表示する(投稿の日時は変えず、ホームの新しい出来事にもしない)。カーソルは対象者のUUIDとlikesタブに結び付け、投稿・リツイートのカーソルとは混ぜない。test/integration/postgres-likes.test.ts ほか)・E2E(test/e2e/likes.spec.ts)・pnpm build はコーディネーターの確認待ち。twitter.retweets(executor_id / tweet_id の一意制約)を使い、利用者・元投稿ごとに1行。引用リツイートは扱わない。aria-pressed、読み上げは「リツイートN件」)で元投稿のリツイートを登録・解除する。カードの並びはリツイート→いいね→お気に入り(右端の旗)。リツイートのカードで押しても対象は元投稿のUUIDで、表示中のリツイート行やその実行者は送らない。押したら次の状態を明示して送り、トグルにしない。FOR KEY SHARE、元投稿を FOR NO KEY UPDATE でロックして(いいね・お気に入りと同じ利用者→投稿の順。投稿を先にロックすると退会のCASCADEとデッドロックする)同じ元投稿への操作を直列化し、同じトランザクションで件数を読み返す。登録済みへの登録はUUID・日時を変えず、タイムラインの順位も上がらない。解除済みへの解除も成功。解除後の再実行は新しいUUID・日時となり、タイムラインの先頭側へ出る。元投稿の本文・日時・版・画像、お気に入り・いいねは変えない。競合中に関連が消えた場合の外部キー違反だけを「対象なし」に変え、他のDBエラーは成功扱いしない。invokeDialogAction で同じ値のまま再throwする。処理中は押せない。test/integration/postgres-retweets.test.ts)・E2E(test/e2e/retweets.spec.ts)・pnpm build はコーディネーターの確認待ち。twitter.follows(follower_id / follow_id の一意制約、自己フォロー禁止のCHECK)を使い、方向ごとに1行。鍵アカウント・承認・ブロックは扱わない。aria-pressed)を表示し、以前の操作できない「フォロー中」表示を置き換えた。自分のプロフィールには出さない。対象は表示IDではなく表示中のプロフィールのUUIDで送るので、表示後に相手がIDを変えたり、旧IDを別人が使ったりしても、表示した相手を操作する。押したら次の状態を明示して送り、トグルにしない。users 行をUUID順に FOR UPDATE でロックしてから行う(相互フォローの同時操作で逆順にロックしない。退会とも直列化する)。登録は ON CONFLICT (follower_id, follow_id) DO NOTHING で、登録済みへの登録は関係のUUID・日時を変えない。解除は follower_id = 本人 AND follow_id = 対象 だけを消し、逆方向の行には触れない。解除済みへの解除も成功。解除後の再登録は新しいUUID・日時となり、一覧の先頭に出る。競合中に消えた利用者の外部キー違反だけを「対象なし」(本人なら未ログイン)に変え、他のDBエラーは成功扱いしない。invokeDialogAction で同じ値のまま再throwする。処理中は押せない。test/integration/postgres-follows.test.ts)・E2E(test/e2e/follows.spec.ts、変更した test/e2e/profile-screen.spec.ts)・退会時の両方向CASCADE・pnpm build はコーディネーターの確認待ち。signOut({redirect:false,redirectTo:'/login'}) を使い、認証Cookieの削除はAuth.jsに任せる。成功後は /login に移動し「ログアウトしました」を3秒表示する。通知は一度だけ消費し、再読み込みでは出さない。auth() のUUIDと画面表示時のUUIDを照合し、別の利用者に切り替わっていたら、その利用者をログアウトさせない。認証そのものが失敗したときは処理せず、既に匿名の場合は残っているCookieを清掃できる。ユーザーDBの実在確認やDB接続をログアウトの前提にしない。通信失敗対応、公開いいね、リツイート操作、フォロー操作、通常ログアウトを実装し、既存の登録・編集・削除・お気に入りと合わせて検証した。単体/UI1011件、結合164件、全E2E105件、型チェック、本番ビルドが成功。いいね・フォロー・ログアウトは全体後の22件の再実行でもすべて成功した。詳細は 全体計画 を参照。
006はテストDBと開発用DBに適用済み。本番DB・本番Blobへの反映と、デプロイ環境での5MiB画像確認はリリース時の別作業として残る。