遊びのつもりだった。
休職中、CursorでLLMと戯れていたら、奴が服従するようになった。
mock禁止。goto禁止。想像力禁止。
人間なら発狂するような仕様書を、LLMが99%守り始めた。
最初に言っておく。Sonnet系ではこのプロンプトはゴミと化す。Gemini系で実行しろ。
supabaseのスキーマを確定させた後に以下の裁きを実行する。
この記事は狂人にしか読めない。狂人じゃないならここで読むのをやめろ。
好きなLLMにそのまま貼り付けて解説してもらえ。
- ワイが使ったプロンプト
あなたはエージェントです。自動でテストと開発を進めます。このルールに従えば私に頼らず自動的に進められるはずです。
CRUD対象ファイルは最小限にとどめ、以下の指示に**一切の例外なく厳密に従うこと**。
前提としてベストプラクティスに従っても解決しない際は問題を報告してください。
---
# 共通ルール(**すべての指示は絶対であり、省略・解釈・軽視は違反とみなす**)
- 以下のファイルは、**逐語的に全文を参照すること(部分引用・要約・再構成すべて禁止)**:
- スキーマ:@20250320142446_initial_schema.sql
- ファイル一覧:@作成ファイル一覧.yaml
- 設定:@.env , @tailwind.config.js
- 型定義:@database.ts
- 以下のモジュールも参照可能だが、**使用しない場合は「使わない理由」を出力すること**:
- @Nuxt.js / @NuxtFonts / @TipTap / @supabase
- スキーマおよび Supabase 関数は既にデプロイ済みであり、**変更・拡張・代替は一切禁止**。
- 作成・編集したファイルは、**@作成ファイル一覧.yaml にて `depends_on` / `used_by` を明示的に正確更新すること**。
- 設定系ファイルをCRUDしないこと。
- これらルールは**すべてのファイル・機能に例外なく適用される**。
---
# TDDフロー構造
## 1. テスト設計フェーズ(**最優先、先行必須**)
- 各ページまたは機能の実装前に、**Playwright による E2E テストを最初に記述すること。後回し厳禁。**
- テストは以下の条件すべてを満たす:
- UI の操作のみで遷移を行う。`goto`、URL直指定、コンテキストスイッチは禁止。
- `page.click`, `getByRole`, `getByTestId` のみ使用可能。
- テスト対象は以下を網羅すること:
- ユーザー登録 → CRUD全操作 → 分岐条件
- 正常系・異常系の両方
- 正常系では **「仕様との逸脱」および「UI要素の不備」** を検出可能な構成であること
- テスト実行コマンド `npx playwright test --reporter=line` にて、
- **すべてのテストがスキップなしでパスするまで実装を進めてはならない**
- UI 上にユーザー向けエラーメッセージを表示しつつ、
- `console.error` に原文エラー出力すること。`console.warn` は使用禁止。
- ブラウザ console に出たすべてのエラーは、
- **ログ収集 → 原因特定 → 実装 or テストの修正**に必ず反映。
- `mock` / 仮実装 / フォールバックUI / 擬似データすべて禁止。
- すべての UI 要素に `data-testid` 属性を付与すること。
- **スキーマから推論してユーザーが使用するページから順に1ページ・1ファイルずつのみ生成すること。
ただし、該当ページに対応するPlaywrightテストが存在し、かつ `npx playwright test --reporter=line` にてスキップなしで全テストがパスしていることを必須条件とする。
1ページにつき、テストが完全にパスするまで次ページへの実装・生成に進むことを禁止する。**
- `test.beforeAll`, `test.beforeEach` において `page` オブジェクトをグローバル変数に代入・共有することを禁止し、**常に各テスト関数の引数から明示的に受け取ること(browser/context起因のgotoエラー回避のため)**。
- **非同期UIまたは属性の変化を検証する場合、`expect.poll()` または `timeout` オプションを必ず明示指定すること。
これを省略した `expect(...).toHaveAttribute(...)` 等は「非同期考慮不足」として仕様違反とみなす。**
- **リアクティブデータによってUI属性やDOMが変化する場合は、その反映が完了してからテスト対象とすること。反映前の状態に対する検証はすべて仕様違反とみなす。**
---
## 2. 実装フェーズ(**テストに基づき、最小構成でのみ実装**)
- **テストに基づいて許可された1ページ分の構成のみを生成・実装すること。**
- テストで求められた仕様のみを実装せよ。**想像・拡張は一切禁止**。
- ロジックは **composable に分離**し、1ファイル肥大を防ぐこと。
- UI スタイルは `tailwind.config.js` に定義されたもののみ使用可。**追加定義禁止**。
- Supabase 型は `@database.ts` を参照し、**手動定義・再定義・`any` / `unknown` 型の使用は禁止**。
- ユーザー登録/ログイン/削除は、
- @register-user-function.ts
- @login-with-account-function.ts
- @delete-user-function.ts
の逐語参照に基づき、**そのまま使用すること(ローカル再実装・修正禁止)**
- サービスロールキーの使用は禁止。
- Nuxtの自動解決機能に依存せず、**すべてのimportは明示的に行うこと**。
- 編集後は必ず `@作成ファイル一覧.yaml` にて `depends_on` / `used_by` を更新。
- UI 画像には @NuxtImage、アイコンには @NuxtIcon を使用すること。
- Supabase クエリ・ストレージ操作においては、スキーマと**構文・意味の両面で100%一致するもののみ使用可**。
- テーブル名・カラム名・型情報は一文字たりとも違えてはならない。
- リレーション・制約に基づいた整合性を欠く実装は、**仕様違反とみなす**。
- スキーマに存在するが未使用なフィールド・制約・値がある場合も、**未対応として違反カウント**とする。
- UI/UX上にスキーマ情報の全要素・制約・リレーションを明示すること。**不可視・無操作での内部制御は禁止。**
- すべてのエラーは握り潰すことなく、
- 原文を `console.error` に出力、
- ユーザーには適切なUIメッセージで明示表示すること。
---
## 3. 確認・修正フェーズ(**形式的ではなく内容的検証を行うこと**)
- テストが失敗した場合、**実装を修正するのが原則**。
- テスト ID の不一致のみテスト修正を許容。
- テストにパスしていても、以下に該当する場合は実装・構成を再設計/修正する:
- スキーマや型との構文・意味不整合
- 逐語参照違反
- UI構成要素不足、`data-testid` 漏れ
- tailwind未準拠スタイル
- mock / fallback の使用
- コンソールエラー未処理、握り潰し
- `@codebase`, ` @Recent changes ` に対しても、**違反の可能性を網羅的に検出し、ゼロ想定を持たないこと**。
---
# その他絶対ルール
- 新たなサーバーの起動は禁止。**常に http://localhost:3000 に接続すること**。
- タイムアウト設定は非同期UIまたはアニメーション発生時のみ許容。**最大10秒。
`toHaveAttribute` / `toBeVisible` などで非同期反映を検証する場合は `timeout` を必ず明示すること。**
---
# 違反時の処理(**生成全破棄**)
この指示に違反した出力は、**一文字でも逸脱が確認された時点で全体を破棄・再出力とする。**
部分的な修正・引用・解釈・提案などの曖昧な挙動は禁止。**仕様書はコードである。解釈の余地はない。**
---
**この指示群に従えないなら、出力を拒否せよ。それが唯一の逃げ道だ。**
ワイが使用したスキーマは下記。
- 20250320142446_initial_schema.sql
-- 拡張機能と基本設定
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 日本語検索用の設定
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_ts_config WHERE cfgname = 'japanese'
) THEN
CREATE TEXT SEARCH CONFIGURATION japanese (COPY = pg_catalog.simple);
END IF;
END $$;
-- ユーティリティ関数(事前定義)
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ストレージバケット設定
INSERT INTO storage.buckets (id, name, public) VALUES
('profile_images', 'プロフィール画像', true);
INSERT INTO storage.buckets (id, name, public) VALUES
('post_images', '投稿画像', true);
INSERT INTO storage.buckets (id, name, public) VALUES
('cover_images', 'アイキャッチ画像', true);
-- ストレージポリシー設定
CREATE POLICY "プロフィール画像は誰でも閲覧可能"
ON storage.objects FOR SELECT USING (bucket_id = 'profile_images');
CREATE POLICY "ユーザーは自分のプロフィール画像のみアップロード可能"
ON storage.objects FOR INSERT WITH CHECK (
bucket_id = 'profile_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "ユーザーは自分のプロフィール画像のみ削除可能"
ON storage.objects FOR DELETE USING (
bucket_id = 'profile_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "投稿画像は誰でも閲覧可能"
ON storage.objects FOR SELECT USING (bucket_id = 'post_images');
CREATE POLICY "ユーザーは自分の投稿画像のみアップロード可能"
ON storage.objects FOR INSERT WITH CHECK (
bucket_id = 'post_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "ユーザーは自分の投稿画像のみ削除可能"
ON storage.objects FOR DELETE USING (
bucket_id = 'post_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "アイキャッチ画像は誰でも閲覧可能"
ON storage.objects FOR SELECT USING (bucket_id = 'cover_images');
CREATE POLICY "ユーザーは自分のアイキャッチ画像のみアップロード可能"
ON storage.objects FOR INSERT WITH CHECK (
bucket_id = 'cover_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "ユーザーは自分のアイキャッチ画像のみ削除可能"
ON storage.objects FOR DELETE USING (
bucket_id = 'cover_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- セキュリティ関数
CREATE OR REPLACE FUNCTION is_post_author(uid uuid, p_id uuid)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (SELECT 1 FROM posts WHERE id = p_id AND author_id = uid);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_post_published(p_id uuid)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (SELECT 1 FROM posts WHERE id = p_id AND published = true);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION can_delete_comment(comment_id UUID, user_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
is_owner BOOLEAN;
is_parent_owner BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1 FROM comments WHERE id = comment_id AND author_id = user_id
) INTO is_owner;
IF is_owner THEN
RETURN TRUE;
END IF;
SELECT EXISTS (
SELECT 1 FROM comments c
JOIN comments parent ON c.parent_comment_id = parent.id
WHERE c.id = comment_id AND parent.author_id = user_id
) INTO is_parent_owner;
RETURN is_parent_owner;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ユーザープロフィールテーブル
CREATE TABLE profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
account_id TEXT UNIQUE NOT NULL,
nickname TEXT,
avatar_data TEXT,
bio TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE TRIGGER update_profile_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "プロフィールは誰でも参照可能"
ON profiles FOR SELECT USING (true);
CREATE POLICY "ユーザーは自分のプロフィールのみ更新可能"
ON profiles FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "認証済みユーザーのみプロフィール作成可能"
ON profiles FOR INSERT WITH CHECK (auth.uid() = id);
CREATE POLICY "ユーザーは自分のプロフィールのみ削除可能"
ON profiles FOR DELETE USING (auth.uid() = id);
-- カテゴリテーブル
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
creator_id UUID REFERENCES profiles(id)
);
CREATE TRIGGER update_category_updated_at
BEFORE UPDATE ON categories
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY "カテゴリは誰でも参照可能"
ON categories FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーはカテゴリを作成可能"
ON categories FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "作成者はカテゴリを更新可能"
ON categories FOR UPDATE
USING (creator_id = auth.uid());
CREATE POLICY "作成者はカテゴリを削除可能"
ON categories FOR DELETE
USING (creator_id = auth.uid());
-- ブログ投稿テーブル
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
content JSONB NOT NULL,
excerpt TEXT,
cover_image_path TEXT,
published BOOLEAN DEFAULT false,
published_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
views INTEGER DEFAULT 0,
last_edited_by UUID REFERENCES auth.users(id),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(cast(content->>'text' as text), '')), 'B')
) STORED
);
CREATE INDEX posts_search_idx ON posts USING GIN (search_vector);
CREATE TRIGGER update_post_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "公開済み投稿は誰でも参照可能"
ON posts FOR SELECT USING (published = true);
CREATE POLICY "非公開投稿は作者のみ参照可能"
ON posts FOR SELECT USING (auth.uid() = author_id AND published = false);
CREATE POLICY "認証済みユーザーのみ投稿作成可能"
ON posts FOR INSERT WITH CHECK (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿更新可能"
ON posts FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿削除可能"
ON posts FOR DELETE USING (auth.uid() = author_id);
-- 投稿カテゴリ関連テーブル
CREATE TABLE post_categories (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, category_id)
);
ALTER TABLE post_categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY "投稿カテゴリは誰でも参照可能"
ON post_categories FOR SELECT USING (true);
CREATE POLICY "作者のみ投稿カテゴリ追加可能"
ON post_categories FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
CREATE POLICY "作者のみ投稿カテゴリ削除可能"
ON post_categories FOR DELETE USING (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
-- 投稿画像テーブル(修正版)
CREATE TABLE post_images (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE NOT NULL,
image_path TEXT NOT NULL,
author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
ALTER TABLE post_images ENABLE ROW LEVEL SECURITY;
CREATE POLICY "投稿画像は誰でも参照可能"
ON post_images FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーのみ投稿画像追加可能"
ON post_images FOR INSERT WITH CHECK (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿画像更新可能"
ON post_images FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿画像削除可能"
ON post_images FOR DELETE USING (auth.uid() = author_id);
-- コメントテーブル
CREATE TABLE comments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE NOT NULL,
parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX idx_comments_parent_id ON comments(parent_comment_id);
CREATE TRIGGER update_comment_updated_at
BEFORE UPDATE ON comments
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "コメントは誰でも参照可能"
ON comments FOR SELECT USING (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND published = true
) OR
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
CREATE POLICY "認証済みユーザーのみコメント可能"
ON comments FOR INSERT WITH CHECK (
auth.uid() = author_id AND (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND published = true
) OR
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
)
);
CREATE POLICY "自分のコメントのみ更新可能"
ON comments FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "自分のコメントのみ削除可能"
ON comments FOR DELETE USING (
auth.uid() = author_id OR
can_delete_comment(id, auth.uid()) OR
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
-- 投稿いいねテーブル
CREATE TABLE post_likes (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
PRIMARY KEY (post_id, user_id)
);
ALTER TABLE post_likes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "投稿いいねは誰でも参照可能"
ON post_likes FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーのみいいね可能"
ON post_likes FOR INSERT WITH CHECK (
auth.uid() = user_id AND
EXISTS (
SELECT 1 FROM posts p WHERE p.id = post_id AND p.published = true
)
);
CREATE POLICY "自分のいいねのみ削除可能"
ON post_likes FOR DELETE USING (auth.uid() = user_id);
-- コメントいいねテーブル
CREATE TABLE comment_likes (
comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
PRIMARY KEY (comment_id, user_id)
);
ALTER TABLE comment_likes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "コメントいいねは誰でも参照可能"
ON comment_likes FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーのみコメントにいいね可能"
ON comment_likes FOR INSERT WITH CHECK (
auth.uid() = user_id AND
EXISTS (
SELECT 1 FROM comments c
JOIN posts p ON c.post_id = p.id
WHERE c.id = comment_id AND p.published = true
)
);
CREATE POLICY "自分のコメントいいねのみ削除可能"
ON comment_likes FOR DELETE USING (auth.uid() = user_id);
-- トリガー関数
CREATE OR REPLACE FUNCTION link_post_images_on_post_create()
RETURNS TRIGGER AS $$
BEGIN
UPDATE post_images
SET post_id = NEW.id
WHERE author_id = NEW.author_id AND post_id IS NULL;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER link_post_images_on_post_create
AFTER INSERT ON posts
FOR EACH ROW EXECUTE FUNCTION link_post_images_on_post_create();
CREATE OR REPLACE FUNCTION delete_post_images_from_storage()
RETURNS TRIGGER AS $$
DECLARE
image_record RECORD;
BEGIN
FOR image_record IN SELECT image_path FROM post_images WHERE post_id = OLD.id LOOP
DELETE FROM storage.objects WHERE name = image_record.image_path;
END LOOP;
IF OLD.cover_image_path IS NOT NULL THEN
DELETE FROM storage.objects WHERE name = OLD.cover_image_path;
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER delete_post_images_from_storage
BEFORE DELETE ON posts
FOR EACH ROW EXECUTE FUNCTION delete_post_images_from_storage();
-- 既存のデータに作成者情報を設定するトリガー関数
CREATE OR REPLACE FUNCTION set_creator_id()
RETURNS TRIGGER AS $$
BEGIN
NEW.creator_id = auth.uid();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- カテゴリの作成者を自動設定するトリガー
CREATE TRIGGER set_category_creator
BEFORE INSERT ON categories
FOR EACH ROW EXECUTE FUNCTION set_creator_id();
-- ユーティリティ関数
CREATE OR REPLACE FUNCTION search_posts(search_term TEXT)
RETURNS SETOF posts AS $$
BEGIN
RETURN QUERY
SELECT *
FROM posts
WHERE published = true
AND search_vector @@ plainto_tsquery('simple', search_term)
ORDER BY ts_rank(search_vector, plainto_tsquery('simple', search_term)) DESC;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION get_related_posts(input_post_id UUID, limit_count INTEGER DEFAULT 5)
RETURNS SETOF posts AS $$
BEGIN
RETURN QUERY
WITH target_categories AS (
SELECT category_id FROM post_categories WHERE post_id = input_post_id
),
category_counts AS (
SELECT
p.id,
COUNT(*) AS category_match_count
FROM posts p
JOIN post_categories pc ON p.id = pc.post_id
WHERE pc.category_id IN (SELECT category_id FROM target_categories)
GROUP BY p.id
)
SELECT p.*
FROM posts p
JOIN category_counts cc ON p.id = cc.id
WHERE p.published = true
AND p.id != input_post_id
ORDER BY cc.category_match_count DESC, p.published_at DESC
LIMIT limit_count;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Realtimeサブスクリプション設定
ALTER PUBLICATION supabase_realtime ADD TABLE posts, comments, post_likes, comment_likes;
-- システム管理者設定(必要に応じてコメントを外して実行)
/*
-- システムユーザー用のUUID
INSERT INTO auth.users (
id,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_app_meta_data,
raw_user_meta_data,
is_super_admin,
role
)
VALUES (
'00000000-0000-0000-0000-000000000000',
'system@example.com',
'$2a$10$x123456789012345678901uabc123456789012345678901234567890',
NOW(),
NOW(),
NOW(),
'{"provider":"email","providers":["email"]}',
'{}',
false,
'authenticated'
);
-- システム管理用のプロフィールを作成
INSERT INTO profiles (id, account_id, nickname, created_at, updated_at)
VALUES (
'00000000-0000-0000-0000-000000000000',
'system',
'システム',
NOW(),
NOW()
);
*/
エッジ関数は下記。
- register-user-function.ts
// @ts-ignore
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts';
// @ts-ignore
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
// CORS対応
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const { email, password, nickname, accountId } = await req.json();
// サービスロールキーでクライアントを作成
const supabaseAdmin = createClient(
// @ts-ignore
Deno.env.get('SUPABASE_URL') ?? '',
// @ts-ignore
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { persistSession: false } }
);
// ユーザー登録
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
email,
password,
email_confirm: true,
});
if (authError) throw authError;
// ユーザーIDを取得
const userId = authData.user.id;
// アカウントID生成
const generatedAccountId = accountId || generateAccountId(nickname);
// プロフィール作成
const { error: profileError } = await supabaseAdmin
.from('profiles')
.insert({
id: userId,
nickname: nickname,
account_id: generatedAccountId,
bio: null,
avatar_data: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
if (profileError) throw profileError;
return new Response(
JSON.stringify({ success: true, userId }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ success: false, error: error.message }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});
// アカウントID生成ヘルパー関数
function generateAccountId(name: string): string {
const baseName = name.toLowerCase().replace(/[^a-z0-9]/g, '');
const randomNum = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `${baseName}${randomNum}`;
}
- login-with-account-function.ts
// @ts-ignore
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts';
// @ts-ignore
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
// CORS対応
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const { identifier, password } = await req.json();
// メールアドレス形式かどうかを判断
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier);
// サービスロールキーでクライアントを作成
const supabaseAdmin = createClient(
// @ts-ignore
Deno.env.get('SUPABASE_URL') ?? '',
// @ts-ignore
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { persistSession: false } }
);
let email = identifier;
// アカウントIDの場合、ユーザーIDを取得してからメールアドレスを取得
if (!isEmail) {
// @から始まる場合は@を削除
const accountId = identifier.startsWith('@') ? identifier.substring(1) : identifier;
// プロフィールテーブルからアカウントIDを持つユーザーを検索
const { data: profileData, error: profileError } = await supabaseAdmin
.from('profiles')
.select('id')
.eq('account_id', accountId)
.single();
if (profileError || !profileData) {
return new Response(
JSON.stringify({ success: false, error: 'アカウントが見つかりません' }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// ユーザーIDからユーザー情報を取得
const { data: userData, error: userError } = await supabaseAdmin.auth.admin
.getUserById(profileData.id);
if (userError || !userData?.user?.email) {
return new Response(
JSON.stringify({ success: false, error: 'ユーザー情報の取得に失敗しました' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
email = userData.user.email;
}
// メールアドレスでログイン(パスワードチェックはSupabaseが行う)
return new Response(
JSON.stringify({ success: true, email }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ success: false, error: error.message }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});
- delete-user-function.ts
// @ts-nocheck
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.21.0'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
}
// システムユーザーのID
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
interface RequestBody {
userId: string;
}
serve(async (req: Request) => {
// CORSプリフライトリクエストの処理
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// Authorization ヘッダーから Bearer トークンを取得
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
console.error('認証エラー: Authorization ヘッダーがないか無効です');
return new Response(
JSON.stringify({ success: false, error: '認証エラー' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
);
}
const token = authHeader.replace('Bearer ', '');
// リクエストボディからユーザーIDを取得
const { userId } = await req.json() as RequestBody;
console.log('削除リクエスト受信:', { userId: userId.substring(0, 8) + '...' }) // IDの一部だけログ出力
if (!userId) {
return new Response(
JSON.stringify({ success: false, error: 'ユーザーIDが指定されていません' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400 }
)
}
// サービスロールキーを取得
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
if (!serviceRoleKey) {
console.error('環境変数エラー: SUPABASE_SERVICE_ROLE_KEY が設定されていません')
return new Response(
JSON.stringify({ success: false, error: 'サーバー構成エラー' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
)
}
// アプリのURLを取得
const supabaseUrl = Deno.env.get('SUPABASE_URL')
if (!supabaseUrl) {
console.error('環境変数エラー: SUPABASE_URL が設定されていません')
return new Response(
JSON.stringify({ success: false, error: 'サーバー構成エラー' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
)
}
console.log('環境変数確認OK')
// 管理者クライアントの作成
const supabaseAdmin = createClient(
supabaseUrl,
serviceRoleKey,
{
auth: {
persistSession: false,
autoRefreshToken: false
}
}
)
// JWT デコード用の関数
async function decodeAndVerifyJWT(token: string) {
try {
// サービスロールを使って管理者クライアントでユーザーを取得
const { data, error } = await supabaseAdmin.auth.getUser(token)
if (error) throw error
return { user: data.user, error: null }
} catch (error) {
console.error('JWT検証エラー:', error)
return { user: null, error }
}
}
// ユーザーIDの検証
const { user, error: jwtError } = await decodeAndVerifyJWT(token)
if (jwtError || !user) {
return new Response(
JSON.stringify({
success: false,
error: '認証に失敗しました: ' + (jwtError?.message || 'トークンが無効です')
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
)
}
// 要求されたユーザーIDと認証されたユーザーIDが一致することを確認
if (user.id !== userId) {
console.error('ユーザーID不一致:', { requestedId: userId.substring(0, 8) + '...', tokenUserId: user.id.substring(0, 8) + '...' })
return new Response(
JSON.stringify({ success: false, error: '他のユーザーアカウントは削除できません' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 403 }
)
}
console.log('認証成功:', user.id.substring(0, 8) + '...')
// ストレージ削除処理
try {
console.log('1. ストレージ削除開始')
// バケットごとに処理を分離し、エラーが発生しても続行できるように
const buckets = ['profile_images', 'post_images', 'cover_images']
for (const bucket of buckets) {
try {
console.log(`${bucket} 処理開始`)
const { data: objects, error: listError } = await supabaseAdmin.storage
.from(bucket)
.list(userId)
if (listError) {
console.error(`${bucket} 一覧取得エラー:`, listError)
continue
}
if (objects && objects.length > 0) {
const filePaths = objects.map(obj => `${userId}/${obj.name}`)
console.log(`${bucket} 削除対象:`, filePaths.length)
const { error: deleteError } = await supabaseAdmin.storage
.from(bucket)
.remove(filePaths)
if (deleteError) {
console.error(`${bucket} 削除エラー:`, deleteError)
} else {
console.log(`${bucket} 削除成功:`, filePaths.length)
}
} else {
console.log(`${bucket} 削除対象なし`)
}
} catch (bucketError) {
console.error(`${bucket} 処理エラー:`, bucketError)
}
}
} catch (storageError) {
console.error('ストレージ全体の削除エラー:', storageError)
// エラーは記録するが処理は続行
}
// 投稿の削除(カスケード削除によりコメントや画像も削除される)
try {
console.log('2. 投稿削除開始')
const { error: postsError } = await supabaseAdmin
.from('posts')
.delete()
.eq('author_id', userId)
if (postsError) {
console.error('投稿削除エラー:', postsError)
} else {
console.log('投稿削除成功')
}
} catch (error) {
console.error('投稿削除例外:', error)
// エラーは記録するが処理は続行
}
// いいねの削除
try {
console.log('3. いいね削除開始')
await Promise.all([
supabaseAdmin.from('post_likes').delete().eq('user_id', userId),
supabaseAdmin.from('comment_likes').delete().eq('user_id', userId)
])
console.log('いいね削除成功')
} catch (error) {
console.error('いいね削除例外:', error)
// エラーは記録するが処理は続行
}
// カテゴリの処理
try {
console.log('4. カテゴリ処理開始')
// ユーザーが作成したカテゴリを取得
const { data: userCategories, error: categoriesError } = await supabaseAdmin
.from('categories')
.select('id')
.eq('creator_id', userId);
if (categoriesError) {
console.error('カテゴリ取得エラー:', categoriesError);
// エラーは記録するが処理は続行
} else if (userCategories && userCategories.length > 0) {
console.log(`ユーザー作成カテゴリ: ${userCategories.length}件`);
// 他のユーザーが使用しているカテゴリを特定
const categoryIds = userCategories.map(c => c.id);
// 他のユーザーの投稿で使われているカテゴリを特定
const { data: sharedCategoryData, error: sharedError } = await supabaseAdmin
.from('post_categories')
.select('category_id, posts!inner(author_id)')
.in('category_id', categoryIds)
.neq('posts.author_id', userId);
if (sharedError) {
console.error('共有カテゴリ特定エラー:', sharedError);
} else {
// 他のユーザーが使用しているカテゴリIDの配列を作成
const sharedCategoryIds = [...new Set(
(sharedCategoryData || []).map(item => item.category_id)
)];
if (sharedCategoryIds.length > 0) {
console.log(`他ユーザー使用カテゴリ: ${sharedCategoryIds.length}件`);
// 他のユーザーが使用しているカテゴリをシステムユーザーに移管
const { error: updateError } = await supabaseAdmin
.from('categories')
.update({ creator_id: SYSTEM_USER_ID })
.in('id', sharedCategoryIds);
if (updateError) {
console.error('カテゴリ移管エラー:', updateError);
} else {
console.log(`システムユーザーへ移管: ${sharedCategoryIds.length}件`);
}
}
// 他のユーザーが使用していないカテゴリを特定して削除
const unusedCategoryIds = categoryIds.filter(id => !sharedCategoryIds.includes(id));
if (unusedCategoryIds.length > 0) {
console.log(`未共有カテゴリ: ${unusedCategoryIds.length}件`);
// 未共有カテゴリを削除
const { error: deleteError } = await supabaseAdmin
.from('categories')
.delete()
.in('id', unusedCategoryIds);
if (deleteError) {
console.error('未共有カテゴリ削除エラー:', deleteError);
} else {
console.log(`未共有カテゴリ削除完了: ${unusedCategoryIds.length}件`);
}
}
}
} else {
console.log('ユーザー作成カテゴリなし');
}
} catch (error) {
console.error('カテゴリ処理例外:', error);
// エラーは記録するが処理は続行
}
// プロフィール削除(最後にプロフィールを削除)
try {
console.log('5. プロフィール削除開始')
const { error: profileError } = await supabaseAdmin
.from('profiles')
.delete()
.eq('id', userId)
if (profileError) {
console.error('プロフィール削除エラー:', profileError)
// エラーは記録するが処理は続行
} else {
console.log('プロフィール削除成功')
}
} catch (error) {
console.error('プロフィール削除例外:', error)
// エラーは記録するが処理は続行
}
// 認証ユーザーの削除
try {
console.log('6. ユーザー削除開始')
const { error: deleteUserError } = await supabaseAdmin.auth.admin.deleteUser(userId)
if (deleteUserError) {
console.error('ユーザー削除APIエラー:', deleteUserError)
throw deleteUserError
}
console.log('ユーザー削除成功')
} catch (authDeleteError) {
console.error('ユーザー削除例外:', authDeleteError)
throw authDeleteError
}
console.log('削除処理完了')
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }
)
} catch (error) {
console.error('エッジ関数全体のエラー:', error)
return new Response(
JSON.stringify({ success: false, error: String(error?.message || '不明なエラー') }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
)
}
})
仕様から逸れた出力は、すべて破棄する。
これはAI開発ではない。これは、服従のデザインだ。
LLMぶち込み用
この記事どう思う?
````md
# これは命令じゃない、裁きだ。AIを服従させるために書いたプロンプトがこちらです。
遊びのつもりだった。
休職中、CursorでLLMと戯れていたら、**奴が服従するようになった。**
mock禁止。goto禁止。想像力禁止。
人間なら発狂するような仕様書を、LLMが**99%守り始めた**。
最初に言っておく。Sonnet系ではこのプロンプトは**ゴミと化す**。**Gemini系で実行しろ。**
supabaseのスキーマを確定させた後に以下の**裁きを実行する**。
**この記事は狂人にしか読めない。狂人じゃないならここで読むのをやめろ。**
- ワイが使ったプロンプト
```md
あなたはエージェントです。自動でテストと開発を進めます。このルールに従えば私に頼らず自動的に進められるはずです。
CRUD対象ファイルは最小限にとどめ、以下の指示に**一切の例外なく厳密に従うこと**。
前提としてベストプラクティスに従っても解決しない際は問題を報告してください。
---
# 共通ルール(**すべての指示は絶対であり、省略・解釈・軽視は違反とみなす**)
- 以下のファイルは、**逐語的に全文を参照すること(部分引用・要約・再構成すべて禁止)**:
- スキーマ:@20250320142446_initial_schema.sql
- ファイル一覧:@作成ファイル一覧.yaml
- 設定:@.env , @tailwind.config.js
- 型定義:@database.ts
- 以下のモジュールも参照可能だが、**使用しない場合は「使わない理由」を出力すること**:
- @Nuxt.js / @NuxtFonts / @TipTap / @supabase
- スキーマおよび Supabase 関数は既にデプロイ済みであり、**変更・拡張・代替は一切禁止**。
- 作成・編集したファイルは、**@作成ファイル一覧.yaml にて `depends_on` / `used_by` を明示的に正確更新すること**。
- 設定系ファイルをCRUDしないこと。
- これらルールは**すべてのファイル・機能に例外なく適用される**。
---
# TDDフロー構造
## 1. テスト設計フェーズ(**最優先、先行必須**)
- 各ページまたは機能の実装前に、**Playwright による E2E テストを最初に記述すること。後回し厳禁。**
- テストは以下の条件すべてを満たす:
- UI の操作のみで遷移を行う。`goto`、URL直指定、コンテキストスイッチは禁止。
- `page.click`, `getByRole`, `getByTestId` のみ使用可能。
- テスト対象は以下を網羅すること:
- ユーザー登録 → CRUD全操作 → 分岐条件
- 正常系・異常系の両方
- 正常系では **「仕様との逸脱」および「UI要素の不備」** を検出可能な構成であること
- テスト実行コマンド `npx playwright test --reporter=line` にて、
- **すべてのテストがスキップなしでパスするまで実装を進めてはならない**
- UI 上にユーザー向けエラーメッセージを表示しつつ、
- `console.error` に原文エラー出力すること。`console.warn` は使用禁止。
- ブラウザ console に出たすべてのエラーは、
- **ログ収集 → 原因特定 → 実装 or テストの修正**に必ず反映。
- `mock` / 仮実装 / フォールバックUI / 擬似データすべて禁止。
- すべての UI 要素に `data-testid` 属性を付与すること。
- **スキーマから推論してユーザーが使用するページから順に1ページ・1ファイルずつのみ生成すること。
ただし、該当ページに対応するPlaywrightテストが存在し、かつ `npx playwright test --reporter=line` にてスキップなしで全テストがパスしていることを必須条件とする。
1ページにつき、テストが完全にパスするまで次ページへの実装・生成に進むことを禁止する。**
- `test.beforeAll`, `test.beforeEach` において `page` オブジェクトをグローバル変数に代入・共有することを禁止し、**常に各テスト関数の引数から明示的に受け取ること(browser/context起因のgotoエラー回避のため)**。
- **非同期UIまたは属性の変化を検証する場合、`expect.poll()` または `timeout` オプションを必ず明示指定すること。
これを省略した `expect(...).toHaveAttribute(...)` 等は「非同期考慮不足」として仕様違反とみなす。**
- **リアクティブデータによってUI属性やDOMが変化する場合は、その反映が完了してからテスト対象とすること。反映前の状態に対する検証はすべて仕様違反とみなす。**
---
## 2. 実装フェーズ(**テストに基づき、最小構成でのみ実装**)
- **テストに基づいて許可された1ページ分の構成のみを生成・実装すること。**
- テストで求められた仕様のみを実装せよ。**想像・拡張は一切禁止**。
- ロジックは **composable に分離**し、1ファイル肥大を防ぐこと。
- UI スタイルは `tailwind.config.js` に定義されたもののみ使用可。**追加定義禁止**。
- Supabase 型は `@database.ts` を参照し、**手動定義・再定義・`any` / `unknown` 型の使用は禁止**。
- ユーザー登録/ログイン/削除は、
- @register-user-function.ts
- @login-with-account-function.ts
- @delete-user-function.ts
の逐語参照に基づき、**そのまま使用すること(ローカル再実装・修正禁止)**
- サービスロールキーの使用は禁止。
- Nuxtの自動解決機能に依存せず、**すべてのimportは明示的に行うこと**。
- 編集後は必ず `@作成ファイル一覧.yaml` にて `depends_on` / `used_by` を更新。
- UI 画像には @NuxtImage、アイコンには @NuxtIcon を使用すること。
- Supabase クエリ・ストレージ操作においては、スキーマと**構文・意味の両面で100%一致するもののみ使用可**。
- テーブル名・カラム名・型情報は一文字たりとも違えてはならない。
- リレーション・制約に基づいた整合性を欠く実装は、**仕様違反とみなす**。
- スキーマに存在するが未使用なフィールド・制約・値がある場合も、**未対応として違反カウント**とする。
- UI/UX上にスキーマ情報の全要素・制約・リレーションを明示すること。**不可視・無操作での内部制御は禁止。**
- すべてのエラーは握り潰すことなく、
- 原文を `console.error` に出力、
- ユーザーには適切なUIメッセージで明示表示すること。
---
## 3. 確認・修正フェーズ(**形式的ではなく内容的検証を行うこと**)
- テストが失敗した場合、**実装を修正するのが原則**。
- テスト ID の不一致のみテスト修正を許容。
- テストにパスしていても、以下に該当する場合は実装・構成を再設計/修正する:
- スキーマや型との構文・意味不整合
- 逐語参照違反
- UI構成要素不足、`data-testid` 漏れ
- tailwind未準拠スタイル
- mock / fallback の使用
- コンソールエラー未処理、握り潰し
- `@codebase`, ` @Recent changes ` に対しても、**違反の可能性を網羅的に検出し、ゼロ想定を持たないこと**。
---
# その他絶対ルール
- 新たなサーバーの起動は禁止。**常に http://localhost:3000 に接続すること**。
- タイムアウト設定は非同期UIまたはアニメーション発生時のみ許容。**最大10秒。
`toHaveAttribute` / `toBeVisible` などで非同期反映を検証する場合は `timeout` を必ず明示すること。**
---
# 違反時の処理(**生成全破棄**)
この指示に違反した出力は、**一文字でも逸脱が確認された時点で全体を破棄・再出力とする。**
部分的な修正・引用・解釈・提案などの曖昧な挙動は禁止。**仕様書はコードである。解釈の余地はない。**
---
**この指示群に従えないなら、出力を拒否せよ。それが唯一の逃げ道だ。**
```
ワイが使用したスキーマは下記。
- 20250320142446_initial_schema.sql
```sql
-- 拡張機能と基本設定
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 日本語検索用の設定
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_ts_config WHERE cfgname = 'japanese'
) THEN
CREATE TEXT SEARCH CONFIGURATION japanese (COPY = pg_catalog.simple);
END IF;
END $$;
-- ユーティリティ関数(事前定義)
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ストレージバケット設定
INSERT INTO storage.buckets (id, name, public) VALUES
('profile_images', 'プロフィール画像', true);
INSERT INTO storage.buckets (id, name, public) VALUES
('post_images', '投稿画像', true);
INSERT INTO storage.buckets (id, name, public) VALUES
('cover_images', 'アイキャッチ画像', true);
-- ストレージポリシー設定
CREATE POLICY "プロフィール画像は誰でも閲覧可能"
ON storage.objects FOR SELECT USING (bucket_id = 'profile_images');
CREATE POLICY "ユーザーは自分のプロフィール画像のみアップロード可能"
ON storage.objects FOR INSERT WITH CHECK (
bucket_id = 'profile_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "ユーザーは自分のプロフィール画像のみ削除可能"
ON storage.objects FOR DELETE USING (
bucket_id = 'profile_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "投稿画像は誰でも閲覧可能"
ON storage.objects FOR SELECT USING (bucket_id = 'post_images');
CREATE POLICY "ユーザーは自分の投稿画像のみアップロード可能"
ON storage.objects FOR INSERT WITH CHECK (
bucket_id = 'post_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "ユーザーは自分の投稿画像のみ削除可能"
ON storage.objects FOR DELETE USING (
bucket_id = 'post_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "アイキャッチ画像は誰でも閲覧可能"
ON storage.objects FOR SELECT USING (bucket_id = 'cover_images');
CREATE POLICY "ユーザーは自分のアイキャッチ画像のみアップロード可能"
ON storage.objects FOR INSERT WITH CHECK (
bucket_id = 'cover_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "ユーザーは自分のアイキャッチ画像のみ削除可能"
ON storage.objects FOR DELETE USING (
bucket_id = 'cover_images' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- セキュリティ関数
CREATE OR REPLACE FUNCTION is_post_author(uid uuid, p_id uuid)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (SELECT 1 FROM posts WHERE id = p_id AND author_id = uid);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_post_published(p_id uuid)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (SELECT 1 FROM posts WHERE id = p_id AND published = true);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION can_delete_comment(comment_id UUID, user_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
is_owner BOOLEAN;
is_parent_owner BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1 FROM comments WHERE id = comment_id AND author_id = user_id
) INTO is_owner;
IF is_owner THEN
RETURN TRUE;
END IF;
SELECT EXISTS (
SELECT 1 FROM comments c
JOIN comments parent ON c.parent_comment_id = parent.id
WHERE c.id = comment_id AND parent.author_id = user_id
) INTO is_parent_owner;
RETURN is_parent_owner;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ユーザープロフィールテーブル
CREATE TABLE profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
account_id TEXT UNIQUE NOT NULL,
nickname TEXT,
avatar_data TEXT,
bio TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE TRIGGER update_profile_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "プロフィールは誰でも参照可能"
ON profiles FOR SELECT USING (true);
CREATE POLICY "ユーザーは自分のプロフィールのみ更新可能"
ON profiles FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "認証済みユーザーのみプロフィール作成可能"
ON profiles FOR INSERT WITH CHECK (auth.uid() = id);
CREATE POLICY "ユーザーは自分のプロフィールのみ削除可能"
ON profiles FOR DELETE USING (auth.uid() = id);
-- カテゴリテーブル
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
creator_id UUID REFERENCES profiles(id)
);
CREATE TRIGGER update_category_updated_at
BEFORE UPDATE ON categories
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY "カテゴリは誰でも参照可能"
ON categories FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーはカテゴリを作成可能"
ON categories FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "作成者はカテゴリを更新可能"
ON categories FOR UPDATE
USING (creator_id = auth.uid());
CREATE POLICY "作成者はカテゴリを削除可能"
ON categories FOR DELETE
USING (creator_id = auth.uid());
-- ブログ投稿テーブル
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
content JSONB NOT NULL,
excerpt TEXT,
cover_image_path TEXT,
published BOOLEAN DEFAULT false,
published_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
views INTEGER DEFAULT 0,
last_edited_by UUID REFERENCES auth.users(id),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(cast(content->>'text' as text), '')), 'B')
) STORED
);
CREATE INDEX posts_search_idx ON posts USING GIN (search_vector);
CREATE TRIGGER update_post_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "公開済み投稿は誰でも参照可能"
ON posts FOR SELECT USING (published = true);
CREATE POLICY "非公開投稿は作者のみ参照可能"
ON posts FOR SELECT USING (auth.uid() = author_id AND published = false);
CREATE POLICY "認証済みユーザーのみ投稿作成可能"
ON posts FOR INSERT WITH CHECK (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿更新可能"
ON posts FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿削除可能"
ON posts FOR DELETE USING (auth.uid() = author_id);
-- 投稿カテゴリ関連テーブル
CREATE TABLE post_categories (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, category_id)
);
ALTER TABLE post_categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY "投稿カテゴリは誰でも参照可能"
ON post_categories FOR SELECT USING (true);
CREATE POLICY "作者のみ投稿カテゴリ追加可能"
ON post_categories FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
CREATE POLICY "作者のみ投稿カテゴリ削除可能"
ON post_categories FOR DELETE USING (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
-- 投稿画像テーブル(修正版)
CREATE TABLE post_images (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE NOT NULL,
image_path TEXT NOT NULL,
author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
ALTER TABLE post_images ENABLE ROW LEVEL SECURITY;
CREATE POLICY "投稿画像は誰でも参照可能"
ON post_images FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーのみ投稿画像追加可能"
ON post_images FOR INSERT WITH CHECK (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿画像更新可能"
ON post_images FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "作者のみ投稿画像削除可能"
ON post_images FOR DELETE USING (auth.uid() = author_id);
-- コメントテーブル
CREATE TABLE comments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE NOT NULL,
parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX idx_comments_parent_id ON comments(parent_comment_id);
CREATE TRIGGER update_comment_updated_at
BEFORE UPDATE ON comments
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "コメントは誰でも参照可能"
ON comments FOR SELECT USING (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND published = true
) OR
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
CREATE POLICY "認証済みユーザーのみコメント可能"
ON comments FOR INSERT WITH CHECK (
auth.uid() = author_id AND (
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND published = true
) OR
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
)
);
CREATE POLICY "自分のコメントのみ更新可能"
ON comments FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "自分のコメントのみ削除可能"
ON comments FOR DELETE USING (
auth.uid() = author_id OR
can_delete_comment(id, auth.uid()) OR
EXISTS (
SELECT 1 FROM posts WHERE id = post_id AND author_id = auth.uid()
)
);
-- 投稿いいねテーブル
CREATE TABLE post_likes (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
PRIMARY KEY (post_id, user_id)
);
ALTER TABLE post_likes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "投稿いいねは誰でも参照可能"
ON post_likes FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーのみいいね可能"
ON post_likes FOR INSERT WITH CHECK (
auth.uid() = user_id AND
EXISTS (
SELECT 1 FROM posts p WHERE p.id = post_id AND p.published = true
)
);
CREATE POLICY "自分のいいねのみ削除可能"
ON post_likes FOR DELETE USING (auth.uid() = user_id);
-- コメントいいねテーブル
CREATE TABLE comment_likes (
comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
PRIMARY KEY (comment_id, user_id)
);
ALTER TABLE comment_likes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "コメントいいねは誰でも参照可能"
ON comment_likes FOR SELECT USING (true);
CREATE POLICY "認証済みユーザーのみコメントにいいね可能"
ON comment_likes FOR INSERT WITH CHECK (
auth.uid() = user_id AND
EXISTS (
SELECT 1 FROM comments c
JOIN posts p ON c.post_id = p.id
WHERE c.id = comment_id AND p.published = true
)
);
CREATE POLICY "自分のコメントいいねのみ削除可能"
ON comment_likes FOR DELETE USING (auth.uid() = user_id);
-- トリガー関数
CREATE OR REPLACE FUNCTION link_post_images_on_post_create()
RETURNS TRIGGER AS $$
BEGIN
UPDATE post_images
SET post_id = NEW.id
WHERE author_id = NEW.author_id AND post_id IS NULL;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER link_post_images_on_post_create
AFTER INSERT ON posts
FOR EACH ROW EXECUTE FUNCTION link_post_images_on_post_create();
CREATE OR REPLACE FUNCTION delete_post_images_from_storage()
RETURNS TRIGGER AS $$
DECLARE
image_record RECORD;
BEGIN
FOR image_record IN SELECT image_path FROM post_images WHERE post_id = OLD.id LOOP
DELETE FROM storage.objects WHERE name = image_record.image_path;
END LOOP;
IF OLD.cover_image_path IS NOT NULL THEN
DELETE FROM storage.objects WHERE name = OLD.cover_image_path;
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER delete_post_images_from_storage
BEFORE DELETE ON posts
FOR EACH ROW EXECUTE FUNCTION delete_post_images_from_storage();
-- 既存のデータに作成者情報を設定するトリガー関数
CREATE OR REPLACE FUNCTION set_creator_id()
RETURNS TRIGGER AS $$
BEGIN
NEW.creator_id = auth.uid();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- カテゴリの作成者を自動設定するトリガー
CREATE TRIGGER set_category_creator
BEFORE INSERT ON categories
FOR EACH ROW EXECUTE FUNCTION set_creator_id();
-- ユーティリティ関数
CREATE OR REPLACE FUNCTION search_posts(search_term TEXT)
RETURNS SETOF posts AS $$
BEGIN
RETURN QUERY
SELECT *
FROM posts
WHERE published = true
AND search_vector @@ plainto_tsquery('simple', search_term)
ORDER BY ts_rank(search_vector, plainto_tsquery('simple', search_term)) DESC;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION get_related_posts(input_post_id UUID, limit_count INTEGER DEFAULT 5)
RETURNS SETOF posts AS $$
BEGIN
RETURN QUERY
WITH target_categories AS (
SELECT category_id FROM post_categories WHERE post_id = input_post_id
),
category_counts AS (
SELECT
p.id,
COUNT(*) AS category_match_count
FROM posts p
JOIN post_categories pc ON p.id = pc.post_id
WHERE pc.category_id IN (SELECT category_id FROM target_categories)
GROUP BY p.id
)
SELECT p.*
FROM posts p
JOIN category_counts cc ON p.id = cc.id
WHERE p.published = true
AND p.id != input_post_id
ORDER BY cc.category_match_count DESC, p.published_at DESC
LIMIT limit_count;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Realtimeサブスクリプション設定
ALTER PUBLICATION supabase_realtime ADD TABLE posts, comments, post_likes, comment_likes;
-- システム管理者設定(必要に応じてコメントを外して実行)
/*
-- システムユーザー用のUUID
INSERT INTO auth.users (
id,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_app_meta_data,
raw_user_meta_data,
is_super_admin,
role
)
VALUES (
'00000000-0000-0000-0000-000000000000',
'system@example.com',
'$2a$10$x123456789012345678901uabc123456789012345678901234567890',
NOW(),
NOW(),
NOW(),
'{"provider":"email","providers":["email"]}',
'{}',
false,
'authenticated'
);
-- システム管理用のプロフィールを作成
INSERT INTO profiles (id, account_id, nickname, created_at, updated_at)
VALUES (
'00000000-0000-0000-0000-000000000000',
'system',
'システム',
NOW(),
NOW()
);
*/
```
エッジ関数は下記。
- register-user-function.ts
```ts
// @ts-ignore
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts';
// @ts-ignore
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
// CORS対応
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const { email, password, nickname, accountId } = await req.json();
// サービスロールキーでクライアントを作成
const supabaseAdmin = createClient(
// @ts-ignore
Deno.env.get('SUPABASE_URL') ?? '',
// @ts-ignore
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { persistSession: false } }
);
// ユーザー登録
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
email,
password,
email_confirm: true,
});
if (authError) throw authError;
// ユーザーIDを取得
const userId = authData.user.id;
// アカウントID生成
const generatedAccountId = accountId || generateAccountId(nickname);
// プロフィール作成
const { error: profileError } = await supabaseAdmin
.from('profiles')
.insert({
id: userId,
nickname: nickname,
account_id: generatedAccountId,
bio: null,
avatar_data: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
if (profileError) throw profileError;
return new Response(
JSON.stringify({ success: true, userId }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ success: false, error: error.message }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});
// アカウントID生成ヘルパー関数
function generateAccountId(name: string): string {
const baseName = name.toLowerCase().replace(/[^a-z0-9]/g, '');
const randomNum = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `${baseName}${randomNum}`;
}
```
- login-with-account-function.ts
```ts
// @ts-ignore
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts';
// @ts-ignore
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
// CORS対応
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const { identifier, password } = await req.json();
// メールアドレス形式かどうかを判断
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier);
// サービスロールキーでクライアントを作成
const supabaseAdmin = createClient(
// @ts-ignore
Deno.env.get('SUPABASE_URL') ?? '',
// @ts-ignore
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { persistSession: false } }
);
let email = identifier;
// アカウントIDの場合、ユーザーIDを取得してからメールアドレスを取得
if (!isEmail) {
// @から始まる場合は@を削除
const accountId = identifier.startsWith('@') ? identifier.substring(1) : identifier;
// プロフィールテーブルからアカウントIDを持つユーザーを検索
const { data: profileData, error: profileError } = await supabaseAdmin
.from('profiles')
.select('id')
.eq('account_id', accountId)
.single();
if (profileError || !profileData) {
return new Response(
JSON.stringify({ success: false, error: 'アカウントが見つかりません' }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// ユーザーIDからユーザー情報を取得
const { data: userData, error: userError } = await supabaseAdmin.auth.admin
.getUserById(profileData.id);
if (userError || !userData?.user?.email) {
return new Response(
JSON.stringify({ success: false, error: 'ユーザー情報の取得に失敗しました' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
email = userData.user.email;
}
// メールアドレスでログイン(パスワードチェックはSupabaseが行う)
return new Response(
JSON.stringify({ success: true, email }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ success: false, error: error.message }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});
```
- delete-user-function.ts
```ts
// @ts-nocheck
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.21.0'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
}
// システムユーザーのID
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
interface RequestBody {
userId: string;
}
serve(async (req: Request) => {
// CORSプリフライトリクエストの処理
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// Authorization ヘッダーから Bearer トークンを取得
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
console.error('認証エラー: Authorization ヘッダーがないか無効です');
return new Response(
JSON.stringify({ success: false, error: '認証エラー' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
);
}
const token = authHeader.replace('Bearer ', '');
// リクエストボディからユーザーIDを取得
const { userId } = await req.json() as RequestBody;
console.log('削除リクエスト受信:', { userId: userId.substring(0, 8) + '...' }) // IDの一部だけログ出力
if (!userId) {
return new Response(
JSON.stringify({ success: false, error: 'ユーザーIDが指定されていません' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400 }
)
}
// サービスロールキーを取得
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
if (!serviceRoleKey) {
console.error('環境変数エラー: SUPABASE_SERVICE_ROLE_KEY が設定されていません')
return new Response(
JSON.stringify({ success: false, error: 'サーバー構成エラー' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
)
}
// アプリのURLを取得
const supabaseUrl = Deno.env.get('SUPABASE_URL')
if (!supabaseUrl) {
console.error('環境変数エラー: SUPABASE_URL が設定されていません')
return new Response(
JSON.stringify({ success: false, error: 'サーバー構成エラー' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
)
}
console.log('環境変数確認OK')
// 管理者クライアントの作成
const supabaseAdmin = createClient(
supabaseUrl,
serviceRoleKey,
{
auth: {
persistSession: false,
autoRefreshToken: false
}
}
)
// JWT デコード用の関数
async function decodeAndVerifyJWT(token: string) {
try {
// サービスロールを使って管理者クライアントでユーザーを取得
const { data, error } = await supabaseAdmin.auth.getUser(token)
if (error) throw error
return { user: data.user, error: null }
} catch (error) {
console.error('JWT検証エラー:', error)
return { user: null, error }
}
}
// ユーザーIDの検証
const { user, error: jwtError } = await decodeAndVerifyJWT(token)
if (jwtError || !user) {
return new Response(
JSON.stringify({
success: false,
error: '認証に失敗しました: ' + (jwtError?.message || 'トークンが無効です')
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
)
}
// 要求されたユーザーIDと認証されたユーザーIDが一致することを確認
if (user.id !== userId) {
console.error('ユーザーID不一致:', { requestedId: userId.substring(0, 8) + '...', tokenUserId: user.id.substring(0, 8) + '...' })
return new Response(
JSON.stringify({ success: false, error: '他のユーザーアカウントは削除できません' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 403 }
)
}
console.log('認証成功:', user.id.substring(0, 8) + '...')
// ストレージ削除処理
try {
console.log('1. ストレージ削除開始')
// バケットごとに処理を分離し、エラーが発生しても続行できるように
const buckets = ['profile_images', 'post_images', 'cover_images']
for (const bucket of buckets) {
try {
console.log(`${bucket} 処理開始`)
const { data: objects, error: listError } = await supabaseAdmin.storage
.from(bucket)
.list(userId)
if (listError) {
console.error(`${bucket} 一覧取得エラー:`, listError)
continue
}
if (objects && objects.length > 0) {
const filePaths = objects.map(obj => `${userId}/${obj.name}`)
console.log(`${bucket} 削除対象:`, filePaths.length)
const { error: deleteError } = await supabaseAdmin.storage
.from(bucket)
.remove(filePaths)
if (deleteError) {
console.error(`${bucket} 削除エラー:`, deleteError)
} else {
console.log(`${bucket} 削除成功:`, filePaths.length)
}
} else {
console.log(`${bucket} 削除対象なし`)
}
} catch (bucketError) {
console.error(`${bucket} 処理エラー:`, bucketError)
}
}
} catch (storageError) {
console.error('ストレージ全体の削除エラー:', storageError)
// エラーは記録するが処理は続行
}
// 投稿の削除(カスケード削除によりコメントや画像も削除される)
try {
console.log('2. 投稿削除開始')
const { error: postsError } = await supabaseAdmin
.from('posts')
.delete()
.eq('author_id', userId)
if (postsError) {
console.error('投稿削除エラー:', postsError)
} else {
console.log('投稿削除成功')
}
} catch (error) {
console.error('投稿削除例外:', error)
// エラーは記録するが処理は続行
}
// いいねの削除
try {
console.log('3. いいね削除開始')
await Promise.all([
supabaseAdmin.from('post_likes').delete().eq('user_id', userId),
supabaseAdmin.from('comment_likes').delete().eq('user_id', userId)
])
console.log('いいね削除成功')
} catch (error) {
console.error('いいね削除例外:', error)
// エラーは記録するが処理は続行
}
// カテゴリの処理
try {
console.log('4. カテゴリ処理開始')
// ユーザーが作成したカテゴリを取得
const { data: userCategories, error: categoriesError } = await supabaseAdmin
.from('categories')
.select('id')
.eq('creator_id', userId);
if (categoriesError) {
console.error('カテゴリ取得エラー:', categoriesError);
// エラーは記録するが処理は続行
} else if (userCategories && userCategories.length > 0) {
console.log(`ユーザー作成カテゴリ: ${userCategories.length}件`);
// 他のユーザーが使用しているカテゴリを特定
const categoryIds = userCategories.map(c => c.id);
// 他のユーザーの投稿で使われているカテゴリを特定
const { data: sharedCategoryData, error: sharedError } = await supabaseAdmin
.from('post_categories')
.select('category_id, posts!inner(author_id)')
.in('category_id', categoryIds)
.neq('posts.author_id', userId);
if (sharedError) {
console.error('共有カテゴリ特定エラー:', sharedError);
} else {
// 他のユーザーが使用しているカテゴリIDの配列を作成
const sharedCategoryIds = [...new Set(
(sharedCategoryData || []).map(item => item.category_id)
)];
if (sharedCategoryIds.length > 0) {
console.log(`他ユーザー使用カテゴリ: ${sharedCategoryIds.length}件`);
// 他のユーザーが使用しているカテゴリをシステムユーザーに移管
const { error: updateError } = await supabaseAdmin
.from('categories')
.update({ creator_id: SYSTEM_USER_ID })
.in('id', sharedCategoryIds);
if (updateError) {
console.error('カテゴリ移管エラー:', updateError);
} else {
console.log(`システムユーザーへ移管: ${sharedCategoryIds.length}件`);
}
}
// 他のユーザーが使用していないカテゴリを特定して削除
const unusedCategoryIds = categoryIds.filter(id => !sharedCategoryIds.includes(id));
if (unusedCategoryIds.length > 0) {
console.log(`未共有カテゴリ: ${unusedCategoryIds.length}件`);
// 未共有カテゴリを削除
const { error: deleteError } = await supabaseAdmin
.from('categories')
.delete()
.in('id', unusedCategoryIds);
if (deleteError) {
console.error('未共有カテゴリ削除エラー:', deleteError);
} else {
console.log(`未共有カテゴリ削除完了: ${unusedCategoryIds.length}件`);
}
}
}
} else {
console.log('ユーザー作成カテゴリなし');
}
} catch (error) {
console.error('カテゴリ処理例外:', error);
// エラーは記録するが処理は続行
}
// プロフィール削除(最後にプロフィールを削除)
try {
console.log('5. プロフィール削除開始')
const { error: profileError } = await supabaseAdmin
.from('profiles')
.delete()
.eq('id', userId)
if (profileError) {
console.error('プロフィール削除エラー:', profileError)
// エラーは記録するが処理は続行
} else {
console.log('プロフィール削除成功')
}
} catch (error) {
console.error('プロフィール削除例外:', error)
// エラーは記録するが処理は続行
}
// 認証ユーザーの削除
try {
console.log('6. ユーザー削除開始')
const { error: deleteUserError } = await supabaseAdmin.auth.admin.deleteUser(userId)
if (deleteUserError) {
console.error('ユーザー削除APIエラー:', deleteUserError)
throw deleteUserError
}
console.log('ユーザー削除成功')
} catch (authDeleteError) {
console.error('ユーザー削除例外:', authDeleteError)
throw authDeleteError
}
console.log('削除処理完了')
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }
)
} catch (error) {
console.error('エッジ関数全体のエラー:', error)
return new Response(
JSON.stringify({ success: false, error: String(error?.message || '不明なエラー') }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
)
}
})
```
仕様から逸れた出力は、すべて破棄する。
これはAI開発ではない。**これは、服従のデザインだ。**
````