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 @@ -25,3 +25,8 @@ GET_CREDENTIAL_SECRET=<strong-random-secret-for-pin-encryption>
# Dashboard admin auth
ADMIN_EMAIL_ALLOWLIST=admin1@school.edu,admin2@school.edu
ADMIN_SESSION_SECRET=<strong-random-session-signing-secret>

# Web push notifications (browser delivery)
WEB_PUSH_VAPID_PUBLIC_KEY=<vapid-public-key>
WEB_PUSH_VAPID_PRIVATE_KEY=<vapid-private-key>
WEB_PUSH_VAPID_SUBJECT=https://slugswap.vercel.app
274 changes: 273 additions & 1 deletion apps/dashboard/app/admin-dashboard-client.tsx

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion apps/dashboard/app/api-query-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,37 @@ const API_ENDPOINTS: ApiEndpoint[] = [
{
path: "/api/admin/user-balance",
method: "GET",
description: "User snapshot (?userId=) with GET link, balances, allowance, requester/donor usage",
description: "User snapshot (?userId=) with GET link, balances, allowance, usage, and notification status",
requiresAuth: true,
},
{
path: "/api/admin/test-notification",
method: "POST",
description: "Send a real test notification to a user's active installations",
requiresAuth: true,
bodyExample: JSON.stringify({ userId: "<user-id>" }, null, 2),
},
{
path: "/api/admin/notification-targets",
method: "GET",
description: "List users with active notification installations for admin sends",
requiresAuth: true,
},
{
path: "/api/admin/send-notification",
method: "POST",
description: "Send a custom admin push notification to a user's active installations",
requiresAuth: true,
bodyExample: JSON.stringify(
{
userId: "<user-id>",
title: "SlugSwap admin message",
message: "Dinner service is busier than usual tonight. Claim a little earlier if you can.",
},
null,
2
),
},
{ path: "/api/admin/config", method: "GET", description: "Get pool configuration", requiresAuth: true },
{
path: "/api/admin/config",
Expand Down
237 changes: 237 additions & 0 deletions apps/dashboard/app/api/admin/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { getDonorWeeklyUsageMap } from "@/lib/server/claims/donor-usage";
import { getPacificWeekWindow } from "@/lib/server/timezone";
import { getActiveGetSession } from "@/lib/server/get/session";
import { retrieveAccounts } from "@/lib/server/get/tools";
import {
sendUserAdminNotification,
sendUserTestNotification,
} from "@/lib/server/notifications/donor-spend";
import {
authenticateAdminBearerToken,
clearAdminSessionCookie,
Expand Down Expand Up @@ -913,6 +917,7 @@ async function dispatch(req: NextRequest, ctx: Ctx) {
let donorUsage: {
status: string;
weeklyAmount: number;
notifyOnSpend: boolean;
redeemedThisWeek: number;
reservedThisWeek: number;
remainingThisWeek: number;
Expand Down Expand Up @@ -969,6 +974,7 @@ async function dispatch(req: NextRequest, ctx: Ctx) {
donorUsage = {
status: donation.status,
weeklyAmount,
notifyOnSpend: donation.notifyOnSpend,
redeemedThisWeek,
reservedThisWeek,
remainingThisWeek: weeklyAmount - (redeemedThisWeek + reservedThisWeek),
Expand All @@ -977,6 +983,20 @@ async function dispatch(req: NextRequest, ctx: Ctx) {
};
}

const notificationRows = await db
.select({
id: schema.notificationInstallations.id,
channel: schema.notificationInstallations.channel,
platform: schema.notificationInstallations.platform,
status: schema.notificationInstallations.status,
lastSeenAt: schema.notificationInstallations.lastSeenAt,
updatedAt: schema.notificationInstallations.updatedAt,
})
.from(schema.notificationInstallations)
.where(eq(schema.notificationInstallations.userId, user.id));

const activeNotificationRows = notificationRows.filter((row) => row.status === "active");

return NextResponse.json(
{
user: {
Expand All @@ -996,6 +1016,21 @@ async function dispatch(req: NextRequest, ctx: Ctx) {
},
getBalance,
trackedGetBalanceTotal,
notifications: {
totalInstallations: notificationRows.length,
activeInstallations: activeNotificationRows.length,
channels: Array.from(new Set(activeNotificationRows.map((row) => row.channel))),
platforms: Array.from(new Set(activeNotificationRows.map((row) => row.platform))),
statuses: {
active: notificationRows.filter((row) => row.status === "active").length,
inactive: notificationRows.filter((row) => row.status === "inactive").length,
invalid: notificationRows.filter((row) => row.status === "invalid").length,
},
lastSeenAt: activeNotificationRows
.map((row) => row.lastSeenAt)
.sort((a, b) => b.getTime() - a.getTime())[0]
?.toISOString() ?? null,
},
weeklyAllowance: allowanceInfo,
requesterUsage: {
allTimeClaimsCount: Number(requesterAllTimeClaims[0]?.count || 0),
Expand All @@ -1021,6 +1056,208 @@ async function dispatch(req: NextRequest, ctx: Ctx) {
}
}

if (action === "test-notification") {
if (req.method !== "POST") {
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
}

try {
const adminIdentity = getAdminIdentityFromRequest(req);
const body = (await req.json()) as { userId?: string };
const userId = body.userId?.trim();

if (!userId) {
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
}

const user = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

const result = await sendUserTestNotification({
userId,
adminEmail: adminIdentity?.email ?? null,
});

if (!result.ok) {
const status = result.totalInstallations === 0 ? 409 : 502;
return NextResponse.json(
{ error: result.error || "Failed to send test notification" },
{ status }
);
}

return NextResponse.json(
{
success: true,
userId,
successCount: result.successCount,
totalInstallations: result.totalInstallations,
message: `Sent test notification to ${result.successCount} of ${result.totalInstallations} active installation${result.totalInstallations === 1 ? "" : "s"}.`,
},
{ status: 200 }
);
} catch (error: any) {
console.error("Error sending admin test notification:", error);
return NextResponse.json(
{ error: error?.message || "Internal server error" },
{ status: 500 }
);
}
}

if (action === "notification-targets") {
if (req.method !== "GET") {
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
}

try {
const rows = await db
.select({
userId: schema.users.id,
email: schema.users.email,
name: schema.users.name,
channel: schema.notificationInstallations.channel,
platform: schema.notificationInstallations.platform,
lastSeenAt: schema.notificationInstallations.lastSeenAt,
notifyOnSpend: schema.donations.notifyOnSpend,
})
.from(schema.notificationInstallations)
.innerJoin(schema.users, eq(schema.notificationInstallations.userId, schema.users.id))
.leftJoin(schema.donations, eq(schema.notificationInstallations.userId, schema.donations.userId))
.where(eq(schema.notificationInstallations.status, "active"))
.orderBy(desc(schema.notificationInstallations.lastSeenAt));

const grouped = new Map<
string,
{
userId: string;
email: string | null;
name: string | null;
activeInstallations: number;
channels: Set<string>;
platforms: Set<string>;
lastSeenAt: Date | null;
notifyOnSpend: boolean;
}
>();

for (const row of rows) {
const existing = grouped.get(row.userId);
if (existing) {
existing.activeInstallations += 1;
existing.channels.add(row.channel);
existing.platforms.add(row.platform);
if (row.lastSeenAt && (!existing.lastSeenAt || row.lastSeenAt > existing.lastSeenAt)) {
existing.lastSeenAt = row.lastSeenAt;
}
existing.notifyOnSpend = existing.notifyOnSpend || Boolean(row.notifyOnSpend);
continue;
}

grouped.set(row.userId, {
userId: row.userId,
email: row.email,
name: row.name,
activeInstallations: 1,
channels: new Set([row.channel]),
platforms: new Set([row.platform]),
lastSeenAt: row.lastSeenAt,
notifyOnSpend: Boolean(row.notifyOnSpend),
});
}

const targets = Array.from(grouped.values()).map((target) => ({
userId: target.userId,
email: target.email,
name: target.name,
activeInstallations: target.activeInstallations,
channels: Array.from(target.channels),
platforms: Array.from(target.platforms),
lastSeenAt: target.lastSeenAt?.toISOString() ?? null,
notifyOnSpend: target.notifyOnSpend,
}));

return NextResponse.json({ targets }, { status: 200 });
} catch (error: any) {
console.error("Error fetching notification targets:", error);
return NextResponse.json(
{ error: error?.message || "Internal server error" },
{ status: 500 }
);
}
}

if (action === "send-notification") {
if (req.method !== "POST") {
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
}

try {
const adminIdentity = getAdminIdentityFromRequest(req);
const body = (await req.json()) as {
userId?: string;
title?: string;
message?: string;
};

const userId = body.userId?.trim();
const title = body.title?.trim() || "SlugSwap admin message";
const message = body.message?.trim();

if (!userId) {
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
}
if (!message) {
return NextResponse.json({ error: "Message is required" }, { status: 400 });
}

const user = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

const result = await sendUserAdminNotification({
userId,
title,
body: message,
adminEmail: adminIdentity?.email ?? null,
eventType: "admin_notification",
});

if (!result.ok) {
const status = result.totalInstallations === 0 ? 409 : 502;
return NextResponse.json(
{ error: result.error || "Failed to send notification" },
{ status }
);
}

return NextResponse.json(
{
success: true,
userId,
title,
successCount: result.successCount,
totalInstallations: result.totalInstallations,
message: `Sent message to ${result.successCount} of ${result.totalInstallations} active installation${result.totalInstallations === 1 ? "" : "s"}.`,
},
{ status: 200 }
);
} catch (error: any) {
console.error("Error sending admin notification:", error);
return NextResponse.json(
{ error: error?.message || "Internal server error" },
{ status: 500 }
);
}
}

if (action === "update-allowance") {
if (req.method !== "POST") {
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
Expand Down
11 changes: 11 additions & 0 deletions apps/dashboard/app/api/claims/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@/lib/server/claims/donor-selection";
import { retrieveAccounts, type GetAccount } from "@/lib/server/get/tools";
import { getActiveGetSession } from "@/lib/server/get/session";
import { notifyDonorSpend } from "@/lib/server/notifications/donor-spend";

export const runtime = "nodejs";

Expand Down Expand Up @@ -513,6 +514,16 @@ async function detectRedemption(
.where(eq(schema.userAllowances.id, allowance.id));
}

try {
await notifyDonorSpend({
claimCodeId: claim.id,
donorUserId: claim.donorUserId,
amount: delta,
});
} catch (error) {
console.error("Failed to notify donor about spend:", error);
}

return { amount: delta, accountName: snap.name, redeemedAt: now };
}
}
Expand Down
Loading