# Shadow 계정 메커니즘 (v0)

## 왜 shadow인가

PT 샵의 현실:
- 회원이 앱을 깔지 않아도 트레이너가 운동 데이터를 기록해야 한다.
- 나중에 회원 본인이 참여하고 싶을 때, 기존에 쌓인 데이터가 그대로 본인 계정에 연결되어야 한다.

Supabase Auth는 `email_confirm: false` 옵션으로 비밀번호 없는 auth.user 생성을 지원한다.
이 user는 로그인은 불가능하지만 `auth.users.id`가 존재하므로 FK 제약을 만족한다.

v0에서는 shadow 생성까지만 구현. 본인이 나중에 들어올 때의 "claim" 플로우는 v1.

## DOC-03 — claim 자동화 현황 (2026-04-19)

shadow → active 전이를 위한 자동화는 아래와 같다:

- **현재 (v0.5)**: shadow user는 같은 `auth.users.id`를 평생 유지하므로
  Supabase Auth가 같은 email로 password 설정을 추가하면 `user_id` 가 그대로
  살아있어 `workout_sessions` / `center_memberships` 등의 FK는 자동 승계된다.
  단 `pending_invitations` 의 stale row는 정리되지 않는다.
- **부분 적용 (PR #42 SEC-02)**: 기존 user 가 invite 받으면 멤버십이 즉시
  생성되지 않고 `pending_invitations(requires_confirmation=true)` 로만 들어간다.
  본인이 `accept_invitation` Edge function 을 호출해야 멤버십이 생성된다.
  email 선점 공격을 막기 위함이지 shadow → active 자동화는 아니다.
- **누락 (v1 작업)**: `auth.on_user_created` 또는 `on_user_updated` 트리거
  가 없어서 shadow user 가 password 를 설정해 active 전환되어도 운영자가
  `pending_invitations` 를 수동으로 정리해야 한다. 트리거 추가 시 다음을
  수행해야 한다:
    1. 새 active user 의 email 과 매치되는 `pending_invitations.confirmed_at IS NULL`
       row 들을 찾는다.
    2. `requires_confirmation=true` 인 row → `accept_invitation` 동등 로직
       호출 (멤버십 생성 + confirmed_at stamp).
    3. `requires_confirmation=false` (back-fill 또는 shadow 직접 생성) row
       → `confirmed_at = now()` 만 stamp.

이슈 추적: [#40](https://github.com/intelli-bruce/RoomfitV2/issues/40).

## 아키텍처

```
[Trainer(ShopHomeScreen)]
  ↓ "멤버 추가" email 입력
[Flutter → supabase.functions.invoke('invite_member')]
  ↓ JSON { center_id, email, role='member' }
[Edge Function]
  1. authorize: caller = staff of center_id
  2. lookup auth.users by email (Admin API)
     - 없으면: admin.createUser({email, email_confirm: false})
     - 있으면: 기존 user.id 사용
  3. center_memberships insert (center_id, user_id, role)
  4. pending_invitations insert (center_id, email, role, invited_by, shadow_user_id)
  ↓ response { user_id, shadow: boolean }
[Flutter UI reload member list]
```

## Edge Function 스켈레톤

파일: `supabase/functions/invite_member/index.ts`

```ts
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;

serve(async (req) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });

  const authHeader = req.headers.get("Authorization");
  if (!authHeader) return json({ error: "missing auth" }, 401);

  const { center_id, email, role } = await req.json();
  if (!center_id || !email || !["trainer", "member"].includes(role)) {
    return json({ error: "invalid_input" }, 400);
  }

  // 호출자 인증
  const userClient = createClient(supabaseUrl, anonKey, {
    global: { headers: { Authorization: authHeader } },
  });
  const { data: { user }, error: userErr } = await userClient.auth.getUser();
  if (userErr || !user) return json({ error: "unauthorized" }, 401);

  // 권한 체크: role=trainer면 호출자가 owner, role=member면 staff
  const admin = createClient(supabaseUrl, serviceKey, {
    auth: { persistSession: false },
  });

  const { data: callerMembership } = await admin
    .from("center_memberships")
    .select("role")
    .eq("center_id", center_id)
    .eq("user_id", user.id)
    .maybeSingle();

  if (!callerMembership) return json({ error: "not_in_center" }, 403);
  if (role === "trainer" && callerMembership.role !== "owner") {
    return json({ error: "only_owner_can_invite_trainer" }, 403);
  }
  if (role === "member" && !["owner", "trainer"].includes(callerMembership.role)) {
    return json({ error: "only_staff_can_invite_member" }, 403);
  }

  // auth.users lookup — v0은 listUsers iterate, v1은 RPC 또는 admin search API로 교체
  const { data: listData } = await admin.auth.admin.listUsers({ perPage: 1000, page: 1 });
  const existing = listData?.users.find(
    (u) => u.email?.toLowerCase() === email.toLowerCase(),
  );

  let targetUserId: string;
  let isShadow = false;

  if (existing) {
    targetUserId = existing.id;
  } else {
    const { data: created, error: createErr } = await admin.auth.admin.createUser({
      email,
      email_confirm: false, // shadow
    });
    if (createErr || !created.user) {
      return json({ error: "create_user_failed", details: createErr?.message }, 500);
    }
    targetUserId = created.user.id;
    isShadow = true;
  }

  // center_memberships insert (upsert)
  const { error: memErr } = await admin
    .from("center_memberships")
    .upsert({ center_id, user_id: targetUserId, role }, { onConflict: "center_id,user_id" });
  if (memErr) return json({ error: "membership_upsert_failed", details: memErr.message }, 500);

  // pending_invitations log
  await admin
    .from("pending_invitations")
    .upsert(
      {
        center_id,
        email,
        role,
        invited_by: user.id,
        shadow_user_id: isShadow ? targetUserId : null,
      },
      { onConflict: "center_id,email" },
    );

  return json({ user_id: targetUserId, shadow: isShadow }, 200);
});

function json(body: unknown, status = 200) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json" },
  });
}
```

## 보안 고려

- `SUPABASE_SERVICE_ROLE_KEY`는 Edge function 환경변수에만 저장. 앱에 절대 노출 금지.
- 호출자 권한 체크 우회 금지: `userClient.auth.getUser()` 로 JWT 검증 후 `center_memberships` 조회.
- Admin API로 user 생성 시 email confirm 생략 → 피싱 방지 위해 owner/trainer가 이미 신뢰하는 email만 입력한다는 전제.
- 중복 초대 시 upsert로 idempotent. 이미 다른 role로 등록된 경우는 409 반환하도록 추후 보강.
- Shadow user가 우연히 본인 email로 가입 시도하면 Supabase가 "이미 존재" 반환. UI에서 "관리자에게 문의" 안내 (v1 claim 플로우까지).
- `admin.auth.admin.listUsers`는 O(N) 탐색. 사용자 수가 증가하면 `GET /auth/v1/admin/users?email=...` 또는 RPC로 전환 필요.

## Flutter 측 호출

```dart
// lib/features/shop/data/supabase_shop_repository.dart
Future<InviteResult> invite({
  required String centerId,
  required String email,
  required MembershipRole role,
}) async {
  final response = await _client.functions.invoke(
    'invite_member',
    body: { 'center_id': centerId, 'email': email, 'role': role.name },
  );
  if (response.status != 200) {
    throw ShopException.fromFunctionResponse(response);
  }
  final data = response.data as Map<String, dynamic>;
  return InviteResult(
    userId: data['user_id'] as String,
    isShadow: data['shadow'] as bool,
  );
}
```

## v1 Claim 플로우 (참고)

```
1. Shadow member가 회원가입 시도 (같은 email)
2. Supabase Auth가 기존 email 발견 → password 설정 여부 확인
3. password 없음 → 비밀번호 설정 + email_confirm=true 업데이트
4. 앱이 로그인 성공 → 기존 user.id 그대로 사용 → 과거 supervised 세션들이 자동 소유
```

필요한 추가 작업 (v1):
- Supabase Auth hook 또는 Trigger로 "shadow → active 전환" 감지
- pending_invitations 상태 업데이트
- Member에게 온보딩 안내 (샵 소속 확인)
