diff --git a/.env.example b/.env.example index 3a7ce3e..ee55dde 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ ADMIN_ROLE_ID="1234567890" # 昨年度メンバーロールのID(継続メンバー判定に使用) RETURNING_MEMBER_ROLE_ID="1234567890" +# 年度メンバーロールのID(例: Member2026)— オンボーディング完了時・バッチ付与で使用 +MEMBER_ROLE_ID="" + +# メンバー種別・学年・学部・興味分野のロールは Discord ギルドのロール名で自動マッチング。 +# Discord サーバーのロール名をプロフィール値(例: "理工学部", "Python", "学部3年", "学部生")と +# 一致させれば env var の設定なしに自動で付与される。 + # 運営チャンネル通知用の Discord Webhook URL(入会/退会などの運営向け通知を投稿) # Discord チャンネル > 連携サービス > ウェブフック から作成 # 空文字の場合は通知をスキップ diff --git a/.gitignore b/.gitignore index 56a6842..d6c52eb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ yarn-error.log* # env files .env* !.env.example +env.share # vercel .vercel diff --git a/app/api/onboarding/route.ts b/app/api/onboarding/route.ts index bf1536f..bdb21e1 100644 --- a/app/api/onboarding/route.ts +++ b/app/api/onboarding/route.ts @@ -9,6 +9,7 @@ import { buildAdminOnboardingCompletedNotification, } from "@/lib/discord-dm"; import { checkLineGroupMembership } from "@/lib/line-invite"; +import { syncMemberDiscordRoles } from "@/lib/discord-role"; export async function POST() { const session = await auth(); @@ -101,6 +102,15 @@ export async function POST() { ).catch((e) => { console.error("Failed to notify admin channel (onboarding):", e); }); + + syncMemberDiscordRoles(session.user.id, { + memberType: updatedMember.memberType, + year: updatedMember.yearByFiscal?.[String(new Date().getFullYear())], + faculty: updatedMember.enrollments?.find((e) => e.isCurrent)?.faculty, + interests: updatedMember.interests, + }).catch((e) => { + console.error("Failed to sync Discord roles (onboarding):", e); + }); } return NextResponse.json({ success: true }); diff --git a/app/internal/(protected)/admin/page.tsx b/app/internal/(protected)/admin/page.tsx index 78aa908..e2d473a 100644 --- a/app/internal/(protected)/admin/page.tsx +++ b/app/internal/(protected)/admin/page.tsx @@ -1,5 +1,6 @@ import { getUnregisteredMembers } from "@/lib/admin/actions"; import { AdminNotificationPanel } from "@/components/admin/notification-panel"; +import { RoleSyncPanel } from "@/components/admin/role-sync-panel"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Shield, AlertCircle } from "lucide-react"; @@ -45,6 +46,14 @@ export default async function AdminPage() { )} + +
+

Discordロール付与

+

+ 登録済みメンバー全員に年度メンバーロールおよびメンバー種別ロールを一括付与します。 +

+ +
); } diff --git a/components/admin/role-sync-panel.tsx b/components/admin/role-sync-panel.tsx new file mode 100644 index 0000000..d5fe844 --- /dev/null +++ b/components/admin/role-sync-panel.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + type SyncRolesResult, + syncAllMemberDiscordRoles, +} from "@/lib/admin/actions"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ShieldCheck, CheckCircle, AlertCircle, Loader2 } from "lucide-react"; + +export function RoleSyncPanel() { + const [result, setResult] = useState(null); + const [isPending, startTransition] = useTransition(); + + function handleSync() { + setResult(null); + startTransition(async () => { + const res = await syncAllMemberDiscordRoles(); + setResult(res); + }); + } + + return ( +
+ {result && ( + <> + 0 ? "destructive" : "default"}> + {result.failed > 0 ? ( + + ) : ( + + )} + ロール同期結果 + + {result.total}件中 {result.success}件成功 + {result.failed > 0 && `、${result.failed}件失敗`} + + + +
+ + + + + + + + + + + {result.details.map((d) => ( + 0 + ? "border-b bg-destructive/10" + : "border-b" + } + > + + + + + + ))} + +
メンバー付与削除エラー
+
{d.discordUsername}
+
+ {d.discordId} +
+
+ {d.addedRoleIds.length > 0 ? ( +
    + {d.addedRoleIds.map((id) => ( +
  • + {id} +
  • + ))} +
+ ) : ( + + )} +
+ {d.removedRoleIds.length > 0 ? ( +
    + {d.removedRoleIds.map((id) => ( +
  • + {id} +
  • + ))} +
+ ) : ( + + )} +
+ {d.errors.length > 0 ? ( +
    + {d.errors.map((e, i) => ( +
  • + {e} +
  • + ))} +
+ ) : ( + + )} +
+
+ + )} + + + + + + Discordロール一括同期 + + + +

+ 登録済みメンバー全員の Discord ロールをプロフィールと同期します。 + メンバー種別・学年・学部・興味分野は Discord + サーバーのロール名と自動マッチングし、不要になったロールは削除します。 + 年度メンバーロール(全員共通)は環境変数 + + MEMBER_ROLE_ID + + で指定してください。 +

+ +
+
+
+ ); +} diff --git a/lib/admin/actions.ts b/lib/admin/actions.ts index 43e4ba2..e185053 100644 --- a/lib/admin/actions.ts +++ b/lib/admin/actions.ts @@ -3,12 +3,19 @@ import { isAdmin } from "@/lib/auth"; import { getGuildMembers, type DiscordGuildMember } from "@/lib/discord-guild"; import { getMemberRegistrationStatus } from "@/lib/members"; +import { getDb } from "@/lib/firebase"; import { sendDiscordDm, buildRegistrationNudgeMessage, buildOnboardingNudgeMessage, } from "@/lib/discord-dm"; import { getOptoutSubmissionIds } from "@/lib/discord-optout"; +import { + syncMemberDiscordRoles, + getGuildRoleNameMap, + ensureRolesInMap, +} from "@/lib/discord-role"; +import type { EnrollmentRecord } from "@/types/profile"; export type MemberStatus = "unregistered" | "onboarding"; @@ -127,3 +134,129 @@ export async function sendRegistrationNudge( return result; } + +export type MemberSyncDetail = { + discordId: string; + discordUsername: string; + addedRoleIds: string[]; + removedRoleIds: string[]; + errors: string[]; +}; + +export type SyncRolesResult = { + total: number; + success: number; + failed: number; + details: MemberSyncDetail[]; +}; + +export async function syncAllMemberDiscordRoles(): Promise { + if (!(await isAdmin())) { + throw new Error("管理者権限が必要です"); + } + + const db = getDb(); + const snap = await db + .collection("members") + .where("onboardingCompleted", "==", true) + .select( + "discordUsername", + "memberType", + "optedOut", + "yearByFiscal", + "enrollments", + "interests", + ) + .get(); + + const result: SyncRolesResult = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + + const currentYear = String(new Date().getFullYear()); + const targets = snap.docs.filter((doc) => doc.data().optedOut !== true); + result.total = targets.length; + + const roleNameMap = await getGuildRoleNameMap(); + console.log( + `[role-sync] ギルドロール ${roleNameMap.size}件を取得: [${[...roleNameMap.keys()].join(", ")}]`, + ); + console.log(`[role-sync] 対象メンバー: ${result.total}件`); + + // 全メンバーのプロフィール値を集めてまとめてロールを作成(重複作成を防ぐ) + const allProfileKeys = new Set(); + for (const doc of targets) { + const { memberType, yearByFiscal, enrollments, interests } = doc.data(); + const year = (yearByFiscal as Record | undefined)?.[ + currentYear + ]; + const faculty = (enrollments as EnrollmentRecord[] | undefined)?.find( + (e) => e.isCurrent, + )?.faculty; + if (memberType) allProfileKeys.add(memberType); + if (year) allProfileKeys.add(year); + if (faculty) allProfileKeys.add(faculty); + for (const tag of (interests as string[] | undefined) ?? []) { + allProfileKeys.add(tag); + } + } + await ensureRolesInMap([...allProfileKeys], roleNameMap); + + const CONCURRENCY = 2; + for (let i = 0; i < targets.length; i += CONCURRENCY) { + const batch = targets.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async (doc) => { + const { + discordUsername, + memberType, + yearByFiscal, + enrollments, + interests, + } = doc.data(); + const year = (yearByFiscal as Record | undefined)?.[ + currentYear + ]; + const faculty = (enrollments as EnrollmentRecord[] | undefined)?.find( + (e) => e.isCurrent, + )?.faculty; + const roleResult = await syncMemberDiscordRoles( + doc.id, + { memberType, year, faculty, interests }, + roleNameMap, + ); + const detail: MemberSyncDetail = { + discordId: doc.id, + discordUsername: discordUsername ?? doc.id, + addedRoleIds: roleResult.added, + removedRoleIds: roleResult.removed, + errors: roleResult.errors, + }; + result.details.push(detail); + if (roleResult.errors.length > 0) { + result.failed++; + console.error( + `[role-sync] NG ${discordUsername ?? doc.id}: errors=${roleResult.errors.join(" | ")}`, + ); + } else { + result.success++; + console.log( + `[role-sync] OK ${discordUsername ?? doc.id}: added=[${roleResult.added.join(", ") || "なし"}] removed=[${roleResult.removed.join(", ") || "なし"}]`, + ); + } + }), + ); + + if (i + CONCURRENCY < targets.length) { + await sleep(1000); + } + } + + console.log( + `[role-sync] 完了: 成功=${result.success}, 失敗=${result.failed}`, + ); + return result; +} diff --git a/lib/discord-role.ts b/lib/discord-role.ts new file mode 100644 index 0000000..d8d22e3 --- /dev/null +++ b/lib/discord-role.ts @@ -0,0 +1,241 @@ +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +export type SyncRolesResult = { + added: string[]; + removed: string[]; + errors: string[]; +}; + +export type MemberRoleParams = { + memberType?: string; + year?: string; + faculty?: string; + interests?: string[]; +}; + +type DiscordRole = { id: string; name: string }; +type DiscordMember = { roles: string[] }; + +function getGuildAndToken(): { guildId: string; botToken: string } { + const guildId = process.env.DISCORD_GUILD_ID; + const botToken = process.env.DISCORD_BOT_TOKEN; + if (!guildId || !botToken) { + throw new Error("DISCORD_GUILD_ID または DISCORD_BOT_TOKEN が未設定です"); + } + return { guildId, botToken }; +} + +export async function addGuildMemberRole( + userId: string, + roleId: string, +): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch( + `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}/roles/${roleId}`, + { + method: "PUT", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }, + ); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ロール付与失敗: ${response.status} ${error}`); + } +} + +export async function removeGuildMemberRole( + userId: string, + roleId: string, +): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch( + `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}/roles/${roleId}`, + { + method: "DELETE", + headers: { Authorization: `Bot ${botToken}` }, + }, + ); + if (!response.ok && response.status !== 404) { + const error = await response.text(); + throw new Error(`ロール削除失敗: ${response.status} ${error}`); + } +} + +async function fetchGuildRoles(): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, { + headers: { Authorization: `Bot ${botToken}` }, + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ギルドロール取得失敗: ${response.status} ${error}`); + } + return response.json(); +} + +async function fetchGuildMemberRoles(userId: string): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch( + `${DISCORD_API_BASE}/guilds/${guildId}/members/${userId}`, + { headers: { Authorization: `Bot ${botToken}` } }, + ); + if (!response.ok) { + const error = await response.text(); + throw new Error(`メンバー情報取得失敗: ${response.status} ${error}`); + } + const member: DiscordMember = await response.json(); + return member.roles; +} + +async function createGuildRole(name: string): Promise { + const { guildId, botToken } = getGuildAndToken(); + const response = await fetch(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ロール作成失敗: ${response.status} ${error}`); + } + return response.json(); +} + +// ロール名 → ID のマップを返す。バッチ処理では一度だけ呼び出して使い回す。 +export async function getGuildRoleNameMap(): Promise> { + const roles = await fetchGuildRoles(); + return new Map(roles.map((r) => [r.name, r.id])); +} + +// マップにない名前のロールを Discord に作成してマップを更新する。 +export async function ensureRolesInMap( + names: string[], + roleNameMap: Map, +): Promise { + const missing = [...new Set(names)].filter((n) => n && !roleNameMap.has(n)); + for (const name of missing) { + try { + const role = await createGuildRole(name); + roleNameMap.set(role.name, role.id); + console.log(`[role-sync] ロール作成: "${role.name}" → ${role.id}`); + } catch (e) { + console.error( + `[role-sync] ロール作成失敗 "${name}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + } +} + +function getTargetRoleIds( + params: MemberRoleParams, + roleNameMap: Map, +): { roleIds: string[]; matched: string[]; notFound: string[] } { + const roles: string[] = []; + const matched: string[] = []; + const notFound: string[] = []; + + // 年度メンバーロール(env var で固定指定) + const memberRoleId = process.env.MEMBER_ROLE_ID; + if (memberRoleId) { + roles.push(memberRoleId); + matched.push(`MEMBER_ROLE_ID(${memberRoleId})`); + } + + // メンバー種別・学年・学部・興味分野はロール名でマッチング + const nameKeys = [ + params.memberType, + params.year, + params.faculty, + ...(params.interests ?? []), + ]; + + for (const key of nameKeys) { + if (!key) continue; + const id = roleNameMap.get(key); + if (id) { + roles.push(id); + matched.push(`"${key}"→${id}`); + } else { + notFound.push(`"${key}"`); + } + } + + return { roleIds: [...new Set(roles.filter(Boolean))], matched, notFound }; +} + +export async function syncMemberDiscordRoles( + userId: string, + params: MemberRoleParams, + roleNameMap?: Map, +): Promise { + const map = roleNameMap ?? (await getGuildRoleNameMap()); + + // マップにないロールを作成 + const nameKeys = [ + params.memberType, + params.year, + params.faculty, + ...(params.interests ?? []), + ].filter(Boolean) as string[]; + await ensureRolesInMap(nameKeys, map); + + const { + roleIds: targetRoleIds, + matched, + notFound, + } = getTargetRoleIds(params, map); + + // 現在のメンバーロールを取得して差分を計算 + const currentRoleIds = await fetchGuildMemberRoles(userId); + const currentRoleSet = new Set(currentRoleIds); + const targetRoleSet = new Set(targetRoleIds); + + // このシステムが管理するロールID一覧(roleNameMap に含まれるもの + MEMBER_ROLE_ID) + const managedRoleIds = new Set([ + ...map.values(), + ...(process.env.MEMBER_ROLE_ID ? [process.env.MEMBER_ROLE_ID] : []), + ]); + + const toAdd = targetRoleIds.filter((id) => !currentRoleSet.has(id)); + const toRemove = [...currentRoleSet].filter( + (id) => managedRoleIds.has(id) && !targetRoleSet.has(id), + ); + + console.log( + `[role-sync] ${userId} matched=[${matched.join(", ")}] notFound=[${notFound.join(", ")}] toAdd=[${toAdd.join(", ")}] toRemove=[${toRemove.join(", ")}]`, + ); + + const added: string[] = []; + const removed: string[] = []; + const errors: string[] = []; + + for (const roleId of toAdd) { + try { + await addGuildMemberRole(userId, roleId); + added.push(roleId); + } catch (e) { + errors.push( + `add roleId=${roleId}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + for (const roleId of toRemove) { + try { + await removeGuildMemberRole(userId, roleId); + removed.push(roleId); + } catch (e) { + errors.push( + `remove roleId=${roleId}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + return { added, removed, errors }; +}