Skip to content
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 の設定なしに自動で付与される。

# 新入生ロールのID と 付与対象の判定基準日時
# 管理者ページ「参加日時によるロール付与」のフォーム初期値として使用
# NEW_MEMBER_JOINED_AFTER: ISO 8601 形式(タイムゾーンオフセット付き)
Expand Down
9 changes: 9 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,14 @@ export async function POST() {
).catch((e) => {
console.error("Failed to notify admin channel (onboarding):", e);
});

syncMemberDiscordRoles(session.user.id, {
year: updatedMember.yearByFiscal?.[String(new Date().getFullYear())],
faculty: updatedMember.enrollments?.find((e) => e.isCurrent)?.faculty,
memberType: updatedMember.memberType,
}).catch((e) => {
console.error("Failed to sync Discord roles (onboarding):", e);
});
}

return NextResponse.json({ success: true });
Expand Down
17 changes: 15 additions & 2 deletions app/api/optout/rejoin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { deleteOptoutSubmission } from "@/lib/discord-optout";
import { getMember, markMemberRejoined } from "@/lib/members";
import { sendDiscordDm, buildRejoinCompletedMessage } from "@/lib/discord-dm";
import { fetchDiscordDisplayName } from "@/lib/discord";
import { syncMemberDiscordRoles } from "@/lib/discord-role";

export async function POST() {
const session = await auth();
Expand All @@ -24,9 +25,10 @@ export async function POST() {
);
}

// 再加入歓迎 DM (失敗してもレスポンスには影響させない)
// 再加入歓迎 DM + ロール同期 (失敗してもレスポンスには影響させない)
let member: Awaited<ReturnType<typeof getMember>> = null;
try {
const member = await getMember(discordId);
member = await getMember(discordId);
const displayName =
member?.discordUsername ??
member?.nickname ??
Expand All @@ -37,5 +39,16 @@ export async function POST() {
console.error("Failed to send rejoin DM:", e);
}

// 退会者ロール削除 + 通常ロール付与 (fire-and-forget)
if (member) {
syncMemberDiscordRoles(discordId, {
year: member.yearByFiscal?.[String(new Date().getFullYear())],
faculty: member.enrollments?.find((e) => e.isCurrent)?.faculty,
memberType: member.memberType,
}).catch((e) => {
console.error("Failed to sync Discord roles (rejoin):", e);
});
}

return NextResponse.json({ success: true });
}
9 changes: 9 additions & 0 deletions app/internal/(protected)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from "next/link";
import { getUnregisteredMembers } from "@/lib/admin/actions";
import { AdminNotificationPanel } from "@/components/admin/notification-panel";
import { RoleSyncPanel } from "@/components/admin/role-sync-panel";
import { RoleAssignmentPanel } from "@/components/admin/role-assignment-panel";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -83,6 +84,14 @@ export default async function AdminPage() {
)}
</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>

<section className="mt-12">
<h2 className="text-lg font-semibold mb-4">参加日時によるロール付与</h2>
<p className="text-sm text-muted-foreground mb-4">
Expand Down
5 changes: 1 addition & 4 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type React from "react";
import type { Metadata } from "next";
import Script from "next/script";
import { Noto_Sans_JP } from "next/font/google";
import "./globals.css";
import Header from "@/components/header";
Expand Down Expand Up @@ -70,9 +69,7 @@ export default function RootLayout({
return (
<html lang="ja" suppressHydrationWarning>
<head>
<Script
id="theme-init"
strategy="beforeInteractive"
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme")||"light";if(t==="system")t=matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.classList.add(t);document.documentElement.style.colorScheme=t}catch(e){}})()`,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
buildAdminOptoutNotification,
} from "@/lib/discord-dm";
import { fetchDiscordDisplayName } from "@/lib/discord";
import { syncMemberDiscordRoles } from "@/lib/discord-role";
import OptoutStatusCard from "@/components/optout/status-card";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -169,6 +170,11 @@ export default async function OptoutConfirmPage({
console.error("Failed to notify admin channel (optout):", e);
}

// 退会者ロール付与 (fire-and-forget)
syncMemberDiscordRoles(discordId, { optedOut: true }).catch((e) => {
console.error("Failed to sync Discord roles (optout):", e);
});

// 副作用完了後は専用ページへリダイレクト (リロードでも副作用が再走しないように)
redirect("/optout/done");
}
145 changes: 145 additions & 0 deletions components/admin/role-sync-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"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.addedRoleNames ?? []).length > 0 ? (
<ul className="space-y-0.5">
{(d.addedRoleNames ?? []).map((name) => (
<li key={name} className="text-xs">
{name}
</li>
))}
</ul>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-3 py-2">
{(d.removedRoleNames ?? []).length > 0 ? (
<ul className="space-y-0.5">
{(d.removedRoleNames ?? []).map((name) => (
<li key={name} className="text-xs text-orange-600">
{name}
</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>
);
}
Loading
Loading