Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 チャンネル > 連携サービス > ウェブフック から作成
# 空文字の場合は通知をスキップ
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ yarn-error.log*
# env files
.env*
!.env.example
env.share

# vercel
.vercel
Expand Down
10 changes: 10 additions & 0 deletions app/api/onboarding/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
9 changes: 9 additions & 0 deletions app/internal/(protected)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -45,6 +46,14 @@ export default async function AdminPage() {
<AdminNotificationPanel members={members!} />
)}
</section>

<section className="mt-10">
<h2 className="text-lg font-semibold mb-4">Discordロール付与</h2>
<p className="text-sm text-muted-foreground mb-4">
登録済みメンバー全員に年度メンバーロールおよびメンバー種別ロールを一括付与します。
</p>
<RoleSyncPanel />
</section>
</div>
);
}
148 changes: 148 additions & 0 deletions components/admin/role-sync-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<SyncRolesResult | null>(null);
const [isPending, startTransition] = useTransition();

function handleSync() {
setResult(null);
startTransition(async () => {
const res = await syncAllMemberDiscordRoles();
setResult(res);
});
}

return (
<div className="space-y-4">
{result && (
<>
<Alert variant={result.failed > 0 ? "destructive" : "default"}>
{result.failed > 0 ? (
<AlertCircle className="h-4 w-4" />
) : (
<CheckCircle className="h-4 w-4" />
)}
<AlertTitle>ロール同期結果</AlertTitle>
<AlertDescription>
{result.total}件中 {result.success}件成功
{result.failed > 0 && `、${result.failed}件失敗`}
</AlertDescription>
</Alert>

<div className="rounded-md border text-sm overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50 text-left">
<th className="px-3 py-2 font-medium">メンバー</th>
<th className="px-3 py-2 font-medium">付与</th>
<th className="px-3 py-2 font-medium">削除</th>
<th className="px-3 py-2 font-medium">エラー</th>
</tr>
</thead>
<tbody>
{result.details.map((d) => (
<tr
key={d.discordId}
className={
d.errors.length > 0
? "border-b bg-destructive/10"
: "border-b"
}
>
<td className="px-3 py-2">
<div>{d.discordUsername}</div>
<div className="text-xs text-muted-foreground">
{d.discordId}
</div>
</td>
<td className="px-3 py-2">
{d.addedRoleIds.length > 0 ? (
<ul className="space-y-0.5">
{d.addedRoleIds.map((id) => (
<li key={id} className="font-mono text-xs">
{id}
</li>
))}
</ul>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-3 py-2">
{d.removedRoleIds.length > 0 ? (
<ul className="space-y-0.5">
{d.removedRoleIds.map((id) => (
<li
key={id}
className="font-mono text-xs text-orange-600"
>
{id}
</li>
))}
</ul>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-3 py-2">
{d.errors.length > 0 ? (
<ul className="space-y-0.5 text-destructive">
{d.errors.map((e, i) => (
<li key={i} className="text-xs">
{e}
</li>
))}
</ul>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}

<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Discordロール一括同期
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
登録済みメンバー全員の Discord ロールをプロフィールと同期します。
メンバー種別・学年・学部・興味分野は Discord
サーバーのロール名と自動マッチングし、不要になったロールは削除します。
年度メンバーロール(全員共通)は環境変数
<code className="text-xs bg-muted px-1 rounded">
MEMBER_ROLE_ID
</code>
で指定してください。
</p>
<Button onClick={handleSync} disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="h-4 w-4" />
)}
{isPending ? "同期中..." : "全メンバーのロールを同期"}
</Button>
</CardContent>
</Card>
</div>
);
}
133 changes: 133 additions & 0 deletions lib/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<SyncRolesResult> {
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<string>();
for (const doc of targets) {
const { memberType, yearByFiscal, enrollments, interests } = doc.data();
const year = (yearByFiscal as Record<string, string> | 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<string, string> | 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;
}
Loading
Loading