タスク管理やプロジェクト管理ツールを新しくチームに導入しようとしたとき、最初にぶつかる最大の壁は何でしょうか?
機能の豊富さでも、料金の高さでもありません。
「全員にアカウントを作ってもらうのが異常に大変」という問題です。
「アカウントの招待メール送ったから登録してね!」
「あ、メール埋もれちゃったかもです…」
「パスワード何にすればいいですか?」
「とりあえず今週忙しいんで来週やります!」
こうして全員の登録が揃うのを待っているうちに、導入の熱量は冷め、結局「いつも通りのエクセルやスプレッドシート」に戻っていきます。
筆者が開発しているプロジェクト管理SaaS「Taski」でも、この問題に直面しました。
そこで出した結論が、「全員にアカウントを作らせるのを諦める」という設計です。
この記事では、ログイン不要の「仮メンバー(名前のみ)」をその場で作成してタスクを割り振り、後から本人が招待リンクで参加した瞬間に、過去のチケットを自動で引き継ぐ(名寄せする) ためのReact × Supabaseの実装パターンを解説します。
1. 抱えていたジレンマ:フリーテキスト vs 認証ユーザー
タスク管理ツールにおいて、「まだアカウントを持っていないメンバーにタスクを振る」には大きく2つのアプローチがありますが、どちらも致命的な欠点があります。
| アプローチ | 実装 | メリット | 致命的な欠点 |
|---|---|---|---|
| A. 担当者をフリーテキストにする | assignee_name TEXT |
誰の名前でも自由に入力できる | 表記揺れ(「田中」「田中太郎」)が発生。集計やフィルタ、権限管理、通知が崩壊する |
| B. 認証ユーザーにFK制約を貼る | assignee_id REFERENCES auth.users(id) |
RLSやプロフィール結合、通知が綺麗に動く | アカウント登録していない人を1人もアサインできない |
私たちが目指したのは、この両者の良いとこ取りです。
- 導入初期: 管理者1人だけがログインし、他のメンバーは「名前(+任意でメール)」だけ登録して即アサインできる。
- 移行期: 他のメンバーはログインしなくても、ガントチャートやカンバン上で「誰のタスクか」が一目でわかる。
- 本番運用: 後から本人が招待リンクを踏んでサインアップした瞬間、担当していたチケットが全自動で本人のアカウントに名寄せ・引き継ぎされる。
2. DB設計:ポリモーフィックな担当者IDとプレースホルダー
まずは Supabase(PostgreSQL)側の設計です。
仮メンバー専用テーブル
認証ユーザー(auth.users や profiles)とは別に、プロジェクト配下に紐づく仮メンバーテーブルを作ります。
CREATE TABLE public.project_placeholder_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT, -- 任意: 入力しておくと招待時の自動名寄せキーになる
color TEXT NOT NULL DEFAULT 'slate', -- アバター用のテーマカラー
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS: プロジェクト参加者のみ閲覧・編集可能
ALTER TABLE public.project_placeholder_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Members can view placeholder members"
ON public.project_placeholder_members FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.project_members pm
WHERE pm.project_id = project_placeholder_members.project_id
AND pm.user_id = auth.uid()
)
);
チケットテーブル側の工夫(FKを貼らない)
通常、tickets.assignee_id には REFERENCES auth.users(id) を貼りたくなります。
しかし、ここにFKを貼ってしまうと仮メンバーのUUID(project_placeholder_members.id)を格納できません。
そのため、チケット側の assignee_id(および複数担当者用の assignee_ids UUID[])は、あえて外部キー制約を貼らず、UUID型としてポリモーフィックに扱える ようにしておきます。
ALTER TABLE public.tickets
ADD COLUMN assignee_id UUID,
ADD COLUMN assignee_ids UUID[] DEFAULT '{}';
3. React / TypeScript:UIを汚さない「合成メンバー」設計
フロントエンドの各コンポーネント(チケット詳細、カンバン、ガントチャート、担当者セレクト)で、いちいち「この人は実メンバー?仮メンバー?」と条件分岐を書くのは保守性の悪夢です。
そこで、ProjectContext 内で実メンバーと仮メンバーを合体させた combinedMembers(透過的な配列) を生成して配る設計にしました。
// types/index.ts
export interface UserProfile {
id: string;
email: string;
full_name: string;
avatar_url?: string;
is_placeholder?: boolean; // 仮メンバー判定フラグ
placeholder_color?: string; // 仮メンバー用アバターカラー
role?: 'admin' | 'member' | 'guest';
}
export interface ProjectPlaceholderMember {
id: string;
project_id: string;
name: string;
email?: string | null;
color: string;
created_at: string;
}
Context側で以下のようにマッピングします:
// ProjectContext.tsx
const combinedMembers = useMemo<UserProfile[]>(() => {
const placeholderProfiles: UserProfile[] = placeholderMembers.map((pm) => ({
id: pm.id,
email: pm.email || '',
full_name: pm.name,
is_placeholder: true,
placeholder_color: pm.color,
role: 'member',
created_at: pm.created_at,
}));
// 実メンバーと仮メンバーを1つの配列に合体
return [...projectMembers, ...placeholderProfiles];
}, [projectMembers, placeholderMembers]);
これにより得られる絶大なメリット
-
UIコンポーネントの修正がほぼ不要:
<select>や担当者ピッカーは、従来どおりallMembers.map(m => ...)を描画するだけで、実メンバーと仮メンバーが自然に並びます。 -
アバターの自動生成:
アバター表示コンポーネント(Avatar.tsx)側でmember.is_placeholderを見て、SVG頭文字アイコン+指定カラーを出すだけで統一感のあるUIになります。 -
通知の安全弁:
メール送信ロジックやSlack連携では、!member.is_placeholderを1箇所チェックするだけで、ログインしていない人に無効なメールが送られる事故を防げます。
// メール通知の宛先選定時
const targetRecipients = ticket.assignees.filter((m) => !m.is_placeholder);
4. 自動名寄せパイプライン:本人が参加した瞬間に引き継ぐ
この仕組みの真骨頂は、「後から本人が参加したときの引き継ぎ処理」です。
本人が招待リンクを踏んでプロジェクトに参加した際、フロントエンドの同期フックが起動し、メールアドレスまたは名前を照合して自動でチケットの担当者を書き換えます。
// DataContext.tsx
const hasAutoTransferredRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!placeholderMembers.length || !allMembers.length) return;
const realMembers = allMembers.filter((m) => !m.is_placeholder);
if (!realMembers.length) return;
for (const pm of placeholderMembers) {
if (hasAutoTransferredRef.current.has(pm.id)) continue;
const pmEmail = (pm.email || '').trim().toLowerCase();
const pmName = normalizeName(pm.name || '');
// ① メールアドレス一致、または ② 氏名の正規化一致を探索
const matchedRealMember = realMembers.find((rm) => {
const rmEmail = (rm.email || '').trim().toLowerCase();
if (pmEmail && rmEmail && pmEmail === rmEmail) return true;
const rmName = normalizeName(rm.full_name || '');
return pmName && rmName && (pmName === rmName || pmName.includes(rmName));
});
if (matchedRealMember) {
hasAutoTransferredRef.current.add(pm.id);
// 自動引き継ぎを実行
transferPlaceholderMember(pm.id, matchedRealMember.id).then((res) => {
if (!res.error) {
console.log(
`🎉 仮メンバー「${pm.name}」の担当チケット(${res.count}件)を` +
`参加した「${matchedRealMember.full_name}」へ自動引き継ぎしました!`
);
}
});
}
}
}, [placeholderMembers, allMembers]);
チケットの担当者付け替え処理
引き継ぎ関数は、対象のチケットを洗い出し、assignee_id および複数担当配列(assignee_ids)内の旧UUIDを新ユーザーのUUIDへ置換してDBを更新します。完了後、役目を終えた仮メンバーを削除します。
const transferPlaceholderMember = async (placeholderId: string, targetUserId: string) => {
// 1. 仮メンバーが割り当てられているチケットを抽出
const targetTickets = tickets.filter(
(t) => t.assignee_id === placeholderId || t.assignee_ids?.includes(placeholderId)
);
// 2. チケットの担当者を新ユーザーIDへ一括置換
for (const t of targetTickets) {
const currentIds = t.assignee_ids || (t.assignee_id ? [t.assignee_id] : []);
const updatedIds = Array.from(
new Set(currentIds.map((id) => (id === placeholderId ? targetUserId : id)))
);
const updatedAssigneeId = t.assignee_id === placeholderId ? targetUserId : t.assignee_id;
await supabase
.from('tickets')
.update({
assignee_id: updatedAssigneeId,
assignee_ids: updatedIds,
})
.eq('id', t.id);
}
// 3. 不要になった仮メンバーレコードを削除
await supabase
.from('project_placeholder_members')
.delete()
.eq('id', placeholderId);
return { count: targetTickets.length };
};
万が一、ニックネーム登録などで名前が自動一致しなかった場合のために、プロジェクト設定画面に手動の「実メンバーへ引き継ぐ」モーダルも用意しています。
5. 悪用・プラン回避を防ぐDB制約(Postgres関数)
SaaS開発者として見落としてはならないのが、「プラン上限の回避」です。
たとえば「無料プランは5名まで」という制限を設けている場合、実メンバーを1人だけにして仮メンバーを99人作られたら、無料のまま大規模運用されてしまいます。
そこで、プロジェクト参加用RPC(join_project_by_invite_code)および作成APIの中で、「実メンバー数 + 仮メンバー数」の合計 で上限チェックを行うようにガードをかけます。
-- 招待参加時の上限チェック関数(PL/pgSQL)
CREATE OR REPLACE FUNCTION public.join_project_by_invite_code(code TEXT)
RETURNS TABLE(id UUID, name TEXT) AS $$
DECLARE
target_id UUID;
max_members INTEGER;
real_member_count INTEGER;
placeholder_member_count INTEGER;
total_count INTEGER;
BEGIN
SELECT p.id INTO target_id FROM public.projects p WHERE p.invite_code = code;
IF target_id IS NULL THEN
RAISE EXCEPTION '招待コードが無効です。';
END IF;
-- プランの上限人数を取得
max_members := public.plan_max_members(public.project_plan(target_id));
IF max_members IS NOT NULL THEN
-- 実メンバー数
SELECT COUNT(*) INTO real_member_count
FROM public.project_members WHERE project_id = target_id;
-- 仮メンバー数
SELECT COUNT(*) INTO placeholder_member_count
FROM public.project_placeholder_members WHERE project_id = target_id;
total_count := real_member_count + placeholder_member_count;
-- 合計人数が上限を超えている場合は参加を拒否
IF total_count >= max_members THEN
RAISE EXCEPTION 'メンバー数の上限(%名、仮メンバー含む)に達しています。', max_members;
END IF;
END IF;
INSERT INTO public.project_members (project_id, user_id, role)
VALUES (target_id, auth.uid(), 'member')
ON CONFLICT DO NOTHING;
RETURN QUERY SELECT p.id, p.name FROM public.projects p WHERE p.id = target_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
これにより、ビジネスモデルの健全性を保ちながら、スムーズなオンボーディングを提供できます。
まとめ:ツールの導入摩擦は「技術」でゼロにできる
タスク管理ツールが現場に定着しない最大の理由は、「使い始めるまでの手続きの多さ」です。
- 最初から全員にサインアップを強要しない
- 名前を入力するだけで1秒でチケットを割り振れる
- 後から参加したときに過去の作業実績が自動で紐付く
この「引き算の設計」を取り入れたことで、導入時の心理的ハードルは劇的に下がりました。
認証とエンティティをガチガチに1対1結合するのではなく、「未認証エンティティを許容し、後から綺麗に昇格させる」というアーキテクチャは、BtoB SaaSやコラボレーションツールを作る上で非常に汎用性の高いアプローチだと感じています。
実際に触って試せます
この記事で紹介した「ログイン不要の仮メンバー作成」や「ガントチャート・カンバンでの割り当て体験」は、Taskiのデモ画面(登録不要・1秒で起動)でそのまま触ることができます。