Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ NEW_MEMBER_JOINED_AFTER=""
# 空文字の場合は通知をスキップ
ADMIN_NOTIFICATION_CHANNEL_WEBHOOK=""

# Cron ジョブ認証シークレット(/api/cron/* エンドポイントの保護)
# `openssl rand -hex 32` で生成。Cloud Scheduler の Authorization ヘッダーに設定する。
# 空文字の場合は認証をスキップ(ローカル開発用)
CRON_SECRET=""

# --- GitHub OAuth (SNS連携) ---
# GitHub Developer Settings > OAuth Apps で取得
# Callback URL: http://localhost:3000/api/auth/link/github/callback
Expand Down
66 changes: 66 additions & 0 deletions app/api/cron/birthday/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { getDb } from "@/lib/firebase";
import { profileToMemberInternal } from "@/lib/members";
import type { MemberDocument } from "@/lib/members";
import {
notifyAdminChannel,
buildBirthdayNotification,
} from "@/lib/discord-dm";

// Cloud Scheduler から呼び出される誕生日通知エンドポイント。
// 認証: Authorization: Bearer {CRON_SECRET} ヘッダーで保護。
//
// Cloud Scheduler 設定例:
// URL: https://<your-app>/api/cron/birthday
// Schedule: 0 9 * * * (毎朝 9:00 JST = 0:00 UTC)
// HTTP method: GET
// Headers: Authorization: Bearer <CRON_SECRET>

export async function GET(request: Request) {
const secret = process.env.CRON_SECRET;
if (secret) {
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}

const db = getDb();
const snap = await db
.collection("members")
.where("onboardingCompleted", "==", true)
.get();

const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, "0");
const dd = String(now.getDate()).padStart(2, "0");
const today = `${mm}-${dd}`;

const todayMembers = snap.docs.flatMap((doc) => {
const data = doc.data() as MemberDocument;
if (data.optedOut) return [];
const member = profileToMemberInternal(doc.id, data);
if (!member.birthDate) return [];
if (member.birthDate.slice(5) !== today) return [];
return [member];
});

if (todayMembers.length === 0) {
return NextResponse.json({ notified: false, count: 0 });
}

const names = todayMembers.map((m) => m.nickname || m.name);

try {
await notifyAdminChannel(buildBirthdayNotification(names));
} catch (e) {
console.error("[cron/birthday] Failed to notify:", e);
return NextResponse.json({ error: String(e) }, { status: 500 });
}

return NextResponse.json({
notified: true,
count: todayMembers.length,
names,
});
}
36 changes: 36 additions & 0 deletions app/internal/(protected)/birthdays/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { auth } from "@/lib/auth";
import { getMembersInternal } from "@/lib/members";
import { BirthdayList } from "@/components/birthday-list";
import { Cake } from "lucide-react";

export const dynamic = "force-dynamic";

export default async function BirthdaysPage() {
const [session, members] = await Promise.all([auth(), getMembersInternal()]);

const myBirthDate =
members.find((m) => m.id === session?.user?.id)?.birthDate ?? null;

const entries = members
.filter((m) => m.birthDate)
.map((m) => ({
id: m.id,
name: m.name,
nickname: m.nickname,
birthDate: m.birthDate!,
avatarUrl: m.snsAvatar || m.publicImage || undefined,
}));

return (
<div className="container max-w-2xl py-8 px-4">
<div className="flex items-center gap-3 mb-6">
<Cake className="h-6 w-6" />
<h1 className="text-2xl font-bold">誕生日カレンダー</h1>
<span className="text-sm text-muted-foreground">
{entries.length} 件
</span>
</div>
<BirthdayList entries={entries} myBirthDate={myBirthDate} />
</div>
);
}
23 changes: 21 additions & 2 deletions app/internal/(protected)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from "next/link";
import { auth } from "@/lib/auth";
import { getMember } from "@/lib/members";
import { getMember, getMembersInternal } from "@/lib/members";
import { TodayBirthdayBanner } from "@/components/today-birthday-banner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Expand Down Expand Up @@ -72,10 +73,25 @@ function getProfileCompletionItems(
];
}

function getTodayBirthdayNames(
members: { name: string; nickname?: string; birthDate?: string }[],
): string[] {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, "0");
const dd = String(now.getDate()).padStart(2, "0");
return members
.filter((m) => m.birthDate?.slice(5) === `${mm}-${dd}`)
.map((m) => m.nickname || m.name);
}

export default async function InternalPage() {
const session = await auth();
const member = await getMember(session!.user!.id);
const [member, allMembers] = await Promise.all([
getMember(session!.user!.id),
getMembersInternal(),
]);
const displayName = member?.nickname || session?.user?.name || "メンバー";
const todayBirthdayNames = getTodayBirthdayNames(allMembers);
const items = getProfileCompletionItems(
member as Record<string, unknown> | null,
);
Expand All @@ -92,6 +108,9 @@ export default async function InternalPage() {

return (
<div className="p-4 md:p-6 max-w-4xl mx-auto space-y-6">
{todayBirthdayNames.length > 0 && (
<TodayBirthdayBanner names={todayBirthdayNames} />
)}
{/* Welcome Banner */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-primary p-6 md:p-8 text-white animate-spring-up">
<div className="absolute inset-0 bg-grid-white/[0.05] bg-[size:20px_20px]" />
Expand Down
2 changes: 2 additions & 0 deletions components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
LayoutDashboard,
Users,
CalendarDays,
Cake,
UserCircle,
Settings,
ExternalLink,
Expand Down Expand Up @@ -35,6 +36,7 @@ const NAV_ITEMS = [
{ href: "/internal", icon: LayoutDashboard, label: "ホーム", exact: true },
{ href: "/internal/members", icon: Users, label: "メンバー" },
{ href: "/internal/events", icon: CalendarDays, label: "イベント" },
{ href: "/internal/birthdays", icon: Cake, label: "誕生日" },
{ href: "/internal/profile", icon: UserCircle, label: "プロフィール" },
{ href: "/internal/settings", icon: Settings, label: "設定" },
];
Expand Down
188 changes: 188 additions & 0 deletions components/birthday-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"use client";

import { useMemo } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { formatBirthDate } from "@/lib/date";
import { BirthdayYearCalendar } from "@/components/birthday-year-calendar";

type BirthdayEntry = {
id: string;
name: string;
nickname?: string;
birthDate: string;
avatarUrl?: string;
};

const MONTH_NAMES = [
"1月",
"2月",
"3月",
"4月",
"5月",
"6月",
"7月",
"8月",
"9月",
"10月",
"11月",
"12月",
];

function daysUntilNextBirthday(birthDate: string): number {
const now = new Date();
const [, m, d] = birthDate.split("-").map(Number);
const next = new Date(now.getFullYear(), m - 1, d);
if (
next.getMonth() < now.getMonth() ||
(next.getMonth() === now.getMonth() && next.getDate() < now.getDate())
) {
next.setFullYear(now.getFullYear() + 1);
}
const diff =
next.getTime() -
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
return Math.round(diff / (1000 * 60 * 60 * 24));
}

function MyBirthdayCountdown({ birthDate }: { birthDate: string }) {
const days = daysUntilNextBirthday(birthDate);
if (days === 0) return null;

return (
<div className="mb-6 rounded-xl border bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/30 dark:to-pink-950/30 p-4">
<p className="text-sm text-muted-foreground">あなたの誕生日まで</p>
<p className="text-3xl font-bold text-primary mt-0.5">あと {days} 日</p>
<p className="text-xs text-muted-foreground mt-1">
{formatBirthDate(birthDate)}
</p>
</div>
);
}

export function BirthdayList({
entries,
myBirthDate,
}: {
entries: BirthdayEntry[];
myBirthDate?: string | null;
}) {
const now = new Date();
const todayMonth = now.getMonth() + 1;
const todayDay = now.getDate();

const grouped = useMemo(() => {
const map = new Map<number, BirthdayEntry[]>();
for (const entry of entries) {
const month = parseInt(entry.birthDate.split("-")[1]);
if (!map.has(month)) map.set(month, []);
map.get(month)!.push(entry);
}
for (const list of map.values()) {
list.sort(
(a, b) =>
parseInt(a.birthDate.split("-")[2]) -
parseInt(b.birthDate.split("-")[2]),
);
}
const months: number[] = [];
for (let i = 0; i < 12; i++) {
const m = ((todayMonth - 1 + i) % 12) + 1;
if (map.has(m)) months.push(m);
}
return months.map((m) => ({ month: m, entries: map.get(m)! }));
}, [entries, todayMonth]);

if (entries.length === 0) {
return (
<p className="text-sm text-muted-foreground">
誕生日を公開しているメンバーがいません。
</p>
);
}

return (
<div className="space-y-8">
{myBirthDate && <MyBirthdayCountdown birthDate={myBirthDate} />}

<div className="space-y-8">
{grouped.map(({ month, entries: monthEntries }) => {
const isCurrentMonth = month === todayMonth;
return (
<section key={month}>
<h2
className={`text-lg font-semibold mb-3 flex items-center gap-2 ${
isCurrentMonth ? "text-primary" : ""
}`}
>
{MONTH_NAMES[month - 1]}
{isCurrentMonth && (
<Badge variant="outline" className="text-xs font-normal">
今月
</Badge>
)}
</h2>
<ul className="space-y-2">
{monthEntries.map((entry) => {
const displayName =
entry.nickname && entry.nickname !== entry.name
? entry.nickname
: entry.name;
const isToday =
month === todayMonth &&
parseInt(entry.birthDate.split("-")[2]) === todayDay;
return (
<li
key={entry.id}
className={`p-3 rounded-lg border ${
isToday
? "bg-yellow-50 border-yellow-200 dark:bg-yellow-950/20 dark:border-yellow-900"
: "bg-card"
}`}
>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9 shrink-0">
<AvatarImage
src={entry.avatarUrl}
alt={displayName}
/>
<AvatarFallback className="text-sm">
{displayName.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{displayName}
</p>
{entry.nickname && entry.nickname !== entry.name && (
<p className="text-xs text-muted-foreground truncate">
{entry.name}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isToday && (
<Badge className="bg-yellow-400 text-yellow-900 border-yellow-300 text-xs">
今日
</Badge>
)}
<span className="text-sm text-muted-foreground whitespace-nowrap">
{formatBirthDate(entry.birthDate)}
</span>
</div>
</div>
</li>
);
})}
</ul>
</section>
);
})}
</div>

<div className="pt-4 border-t">
<BirthdayYearCalendar entries={entries} />
</div>
</div>
);
}
Loading
Loading