Add opt-in Discord ping to standings notifications
Users who have linked their Discord account can enable a ping preference in Settings > Notifications. When enabled, their bracket username is replaced with a Discord @mention (<@userId>) in league standings update messages, sending them a push notification and showing their Discord display name. Adds discordPingEnabled column to users, a findDiscordIdsByUserIds model helper, allowed_mentions support to the Discord webhook payload, and a NotificationsSection settings component. https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs
This commit is contained in:
parent
d4a9fc8aaf
commit
927b79bf31
11 changed files with 6415 additions and 20 deletions
78
app/components/user/settings/NotificationsSection.tsx
Normal file
78
app/components/user/settings/NotificationsSection.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useFetcher, Link } from "react-router";
|
||||
|
||||
type Props = {
|
||||
discordPingEnabled: boolean;
|
||||
hasDiscordLinked: boolean;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success }: Props) {
|
||||
const fetcher = useFetcher();
|
||||
const optimisticEnabled =
|
||||
fetcher.state !== "idle"
|
||||
? fetcher.formData?.get("discordPingEnabled") === "true"
|
||||
: discordPingEnabled;
|
||||
|
||||
const handleToggle = () => {
|
||||
fetcher.submit(
|
||||
{ intent: "update-discord-ping", discordPingEnabled: String(!optimisticEnabled) },
|
||||
{ method: "post" }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Notifications</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage how you receive league notifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<h3 className="text-sm font-medium">Discord Pings</h3>
|
||||
|
||||
{!hasDiscordLinked ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Link your Discord account in{" "}
|
||||
<Link to="?section=account" className="text-primary underline-offset-4 hover:underline">
|
||||
Account settings
|
||||
</Link>{" "}
|
||||
to enable Discord pings for league notifications.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">Ping me in standings updates</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, you'll be @mentioned in league Discord notifications and your
|
||||
Discord display name will appear in place of your brackt username.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={optimisticEnabled}
|
||||
onClick={handleToggle}
|
||||
disabled={fetcher.state !== "idle"}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
optimisticEnabled ? "bg-primary" : "bg-input"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${
|
||||
optimisticEnabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{success && (
|
||||
<p className="text-sm text-green-600">Notification preference saved.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -14,3 +14,13 @@ export async function findLinkedAccountsByUserId(userId: string): Promise<Linked
|
|||
.from(schema.accounts)
|
||||
.where(eq(schema.accounts.userId, userId));
|
||||
}
|
||||
|
||||
export async function findDiscordIdsByUserIds(userIds: string[]): Promise<Map<string, string>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
const db = database();
|
||||
const rows = await db
|
||||
.select({ userId: schema.accounts.userId, accountId: schema.accounts.accountId })
|
||||
.from(schema.accounts)
|
||||
.where(and(inArray(schema.accounts.userId, userIds), eq(schema.accounts.providerId, "discord")));
|
||||
return new Map(rows.map((r) => [r.userId, r.accountId]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/di
|
|||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
import { doesLoserAdvance } from "~/models/playoff-match";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
|
@ -1455,6 +1456,12 @@ export async function recalculateAffectedLeagues(
|
|||
.filter((entry): entry is [string, string] => entry[1] !== null)
|
||||
);
|
||||
|
||||
// Build userId → Discord ID map for opted-in users
|
||||
const optedInUserIds = teamOwnerUsers
|
||||
.filter((u) => u.discordPingEnabled)
|
||||
.map((u) => u.id);
|
||||
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
|
||||
|
||||
// Build teamId → ownerId map from standings data
|
||||
const ownerIdByTeamId = new Map(
|
||||
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
||||
|
|
@ -1485,13 +1492,11 @@ export async function recalculateAffectedLeagues(
|
|||
draftPicks.map((p) => [p.participantId, p.teamId])
|
||||
);
|
||||
|
||||
const lookupUsername = (participantId: string | null | undefined) => {
|
||||
const lookupOwner = (participantId: string | null | undefined) => {
|
||||
if (!participantId) return undefined;
|
||||
const teamId = teamIdByParticipantId.get(participantId);
|
||||
if (!teamId) return undefined;
|
||||
const ownerId = ownerIdByTeamId.get(teamId);
|
||||
if (!ownerId) return undefined;
|
||||
return usernameByUserId.get(ownerId);
|
||||
return ownerIdByTeamId.get(teamId);
|
||||
};
|
||||
|
||||
scoredMatches = relevant
|
||||
|
|
@ -1500,13 +1505,17 @@ export async function recalculateAffectedLeagues(
|
|||
const winnerTeamId = m.winnerId ? teamIdByParticipantId.get(m.winnerId) : undefined;
|
||||
const loserTeamId = m.loserId ? teamIdByParticipantId.get(m.loserId) : undefined;
|
||||
const winnerScoreChanged = winnerTeamId ? changedTeamIds.has(winnerTeamId) : false;
|
||||
const winnerOwnerId = lookupOwner(m.winnerId);
|
||||
const loserOwnerId = lookupOwner(m.loserId);
|
||||
const showWinner = winnerScoreChanged;
|
||||
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
|
||||
return {
|
||||
winnerName: m.winnerName ?? "",
|
||||
loserName: m.loserName ?? "",
|
||||
winnerUsername: winnerScoreChanged ? lookupUsername(m.winnerId) : undefined,
|
||||
loserUsername: isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds)
|
||||
? lookupUsername(m.loserId)
|
||||
: undefined,
|
||||
winnerUsername: showWinner && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
|
||||
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
|
||||
winnerDiscordUserId: showWinner && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
|
||||
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1522,6 +1531,7 @@ export async function recalculateAffectedLeagues(
|
|||
teamId: s.teamId,
|
||||
teamName: s.team.name,
|
||||
username: usernameByUserId.get(s.team.ownerId ?? ""),
|
||||
discordUserId: s.team.ownerId ? discordIdByUserId.get(s.team.ownerId) : undefined,
|
||||
totalPoints: parseFloat(s.totalPoints),
|
||||
rank: s.currentRank,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ describe('Team Management - Admin User List', () => {
|
|||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
discordPingEnabled: false,
|
||||
lastDataRequestAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
|
|
@ -213,6 +214,7 @@ describe('Team Management - Admin User List', () => {
|
|||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
discordPingEnabled: false,
|
||||
lastDataRequestAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
|
|
@ -252,8 +254,9 @@ describe('Team Management - Admin User List', () => {
|
|||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
discordPingEnabled: false,
|
||||
lastDataRequestAt: null,
|
||||
deletedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { Key, Lock, Shield, User } from "lucide-react";
|
||||
import { Bell, Key, Lock, Shield, User } from "lucide-react";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { findUserById, isUserInActiveDraft, updateUser, anonymizeUserAccount } from "~/models/user";
|
||||
import { findLinkedAccountsByUserId } from "~/models/account";
|
||||
|
|
@ -21,13 +21,15 @@ import { ProfileSection } from "~/components/user/settings/ProfileSection";
|
|||
import { AccountSection } from "~/components/user/settings/AccountSection";
|
||||
import { ApiSection } from "~/components/user/settings/ApiSection";
|
||||
import { PrivacySection } from "~/components/user/settings/PrivacySection";
|
||||
import { NotificationsSection } from "~/components/user/settings/NotificationsSection";
|
||||
import type { Route } from "./+types/settings";
|
||||
|
||||
type SectionId = "profile" | "account" | "api" | "privacy";
|
||||
type SectionId = "profile" | "account" | "notifications" | "api" | "privacy";
|
||||
|
||||
const SECTIONS: readonly SettingsGridSection[] = [
|
||||
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" },
|
||||
{ id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord pings" },
|
||||
{ id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" },
|
||||
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
|
||||
] as const;
|
||||
|
|
@ -42,6 +44,7 @@ type ActionData =
|
|||
| { intent: "remove-avatar-photo"; success: true }
|
||||
| { intent: "update-profile"; success: true }
|
||||
| { intent: "update-profile"; error: string }
|
||||
| { intent: "update-discord-ping"; success: true }
|
||||
| { intent: "request-data"; dataRequestSuccess: true }
|
||||
| { intent: "request-data"; dataRequestError: string }
|
||||
| { intent: string; error: string };
|
||||
|
|
@ -127,6 +130,12 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
|
|||
return { intent: "update-profile", success: true };
|
||||
}
|
||||
|
||||
if (intent === "update-discord-ping") {
|
||||
const enabled = formData.get("discordPingEnabled") === "true";
|
||||
await updateUser(session.user.id, { discordPingEnabled: enabled });
|
||||
return { intent: "update-discord-ping", success: true };
|
||||
}
|
||||
|
||||
if (intent === "request-data") {
|
||||
if (user.lastDataRequestAt) {
|
||||
const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS);
|
||||
|
|
@ -233,6 +242,13 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
|||
{activeSection === "account" && (
|
||||
<AccountSection email={user.email} linkedAccounts={linkedAccounts} />
|
||||
)}
|
||||
{activeSection === "notifications" && (
|
||||
<NotificationsSection
|
||||
discordPingEnabled={user.discordPingEnabled}
|
||||
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
|
||||
success={ad?.intent === "update-discord-ping" && "success" in ad}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "api" && <ApiSection />}
|
||||
{activeSection === "privacy" && (
|
||||
<PrivacySection
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ function getDescription(): string {
|
|||
return body.embeds[0].description as string;
|
||||
}
|
||||
|
||||
function getPayload(): ReturnType<typeof JSON.parse> {
|
||||
return JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||
}
|
||||
|
||||
describe("sendDiscordWebhook", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch(204));
|
||||
|
|
@ -293,6 +297,115 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).toContain("user\\_two");
|
||||
});
|
||||
|
||||
it("uses Discord mention in standings for opted-in user", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
{
|
||||
teamId: "a",
|
||||
teamName: "Alpha FC",
|
||||
username: "christhrowsrocks",
|
||||
discordUserId: "111222333",
|
||||
totalPoints: 150,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Alpha FC (<@111222333>)");
|
||||
expect(desc).not.toContain("christhrowsrocks");
|
||||
});
|
||||
|
||||
it("uses Discord mention in scored match for opted-in winner", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Real Madrid",
|
||||
loserName: "Bayern Munich",
|
||||
winnerUsername: "manager1",
|
||||
winnerDiscordUserId: "111222333",
|
||||
loserUsername: "manager2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Real Madrid (<@111222333>)");
|
||||
expect(desc).not.toContain("manager1");
|
||||
expect(desc).toContain("Bayern Munich (manager2)");
|
||||
});
|
||||
|
||||
it("sets content and allowed_mentions when opted-in users are present", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
{
|
||||
teamId: "a",
|
||||
teamName: "Alpha FC",
|
||||
username: "christhrowsrocks",
|
||||
discordUserId: "111222333",
|
||||
totalPoints: 150,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.content).toBe("<@111222333>");
|
||||
expect(payload.allowed_mentions).toEqual({ parse: [], users: ["111222333"] });
|
||||
});
|
||||
|
||||
it("omits content and allowed_mentions when no users opted in", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
{ teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 },
|
||||
],
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.content).toBeUndefined();
|
||||
expect(payload.allowed_mentions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects Discord IDs from both standings and scored matches", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
{ teamId: "a", teamName: "Alpha", username: "user1", discordUserId: "111", totalPoints: 150, rank: 1 },
|
||||
],
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Real Madrid",
|
||||
loserName: "Bayern Munich",
|
||||
winnerUsername: "user2",
|
||||
winnerDiscordUserId: "222",
|
||||
loserUsername: "user1",
|
||||
loserDiscordUserId: "111",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
const ids: string[] = payload.allowed_mentions.users;
|
||||
expect(ids).toHaveLength(2);
|
||||
expect(ids).toContain("111");
|
||||
expect(ids).toContain("222");
|
||||
});
|
||||
|
||||
it("does not show fields on the embed", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface DiscordEmbed {
|
|||
interface DiscordWebhookPayload {
|
||||
content?: string;
|
||||
embeds?: DiscordEmbed[];
|
||||
allowed_mentions?: { parse: string[]; users: string[] };
|
||||
}
|
||||
|
||||
export async function sendDiscordWebhook(
|
||||
|
|
@ -37,6 +38,7 @@ export interface StandingEntry {
|
|||
teamId: string;
|
||||
teamName: string;
|
||||
username?: string;
|
||||
discordUserId?: string;
|
||||
totalPoints: number;
|
||||
rank: number;
|
||||
}
|
||||
|
|
@ -46,6 +48,8 @@ export interface ScoredMatch {
|
|||
loserName: string;
|
||||
winnerUsername?: string;
|
||||
loserUsername?: string;
|
||||
winnerDiscordUserId?: string;
|
||||
loserDiscordUserId?: string;
|
||||
}
|
||||
|
||||
export async function sendStandingsUpdateNotification({
|
||||
|
|
@ -83,11 +87,21 @@ export async function sendStandingsUpdateNotification({
|
|||
if (relevantMatches && relevantMatches.length > 0) {
|
||||
sections.push("\n**Scored Matches**");
|
||||
for (const match of relevantMatches) {
|
||||
const winnerLabel = match.winnerUsername
|
||||
? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})`
|
||||
const winnerManagerLabel = match.winnerDiscordUserId
|
||||
? `<@${match.winnerDiscordUserId}>`
|
||||
: match.winnerUsername
|
||||
? escapeMarkdown(match.winnerUsername)
|
||||
: undefined;
|
||||
const loserManagerLabel = match.loserDiscordUserId
|
||||
? `<@${match.loserDiscordUserId}>`
|
||||
: match.loserUsername
|
||||
? escapeMarkdown(match.loserUsername)
|
||||
: undefined;
|
||||
const winnerLabel = winnerManagerLabel
|
||||
? `${escapeMarkdown(match.winnerName)} (${winnerManagerLabel})`
|
||||
: escapeMarkdown(match.winnerName);
|
||||
const loserLabel = match.loserUsername
|
||||
? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})`
|
||||
const loserLabel = loserManagerLabel
|
||||
? `${escapeMarkdown(match.loserName)} (${loserManagerLabel})`
|
||||
: escapeMarkdown(match.loserName);
|
||||
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
|
||||
}
|
||||
|
|
@ -127,8 +141,12 @@ export async function sendStandingsUpdateNotification({
|
|||
}
|
||||
|
||||
const escapedName = escapeMarkdown(s.teamName);
|
||||
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
|
||||
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName;
|
||||
const managerLabel = s.discordUserId
|
||||
? `<@${s.discordUserId}>`
|
||||
: s.username
|
||||
? escapeMarkdown(s.username)
|
||||
: undefined;
|
||||
const label = managerLabel ? `${escapedName} (${managerLabel})` : escapedName;
|
||||
sections.push(`${rankPrefix}\\. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +157,18 @@ export async function sendStandingsUpdateNotification({
|
|||
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
|
||||
}
|
||||
|
||||
await sendDiscordWebhook(webhookUrl, {
|
||||
// Collect Discord user IDs of all opted-in managers appearing in this notification.
|
||||
const pingUserIds = new Set<string>();
|
||||
for (const s of changedTeams) {
|
||||
if (s.discordUserId) pingUserIds.add(s.discordUserId);
|
||||
}
|
||||
for (const m of relevantMatches ?? []) {
|
||||
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
|
||||
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
|
||||
}
|
||||
const pingIds = [...pingUserIds];
|
||||
|
||||
const payload: DiscordWebhookPayload = {
|
||||
embeds: [
|
||||
{
|
||||
title: `📊 Standings Update — ${seasonName}`,
|
||||
|
|
@ -148,5 +177,12 @@ export async function sendStandingsUpdateNotification({
|
|||
footer: { text: "brackt.com" },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
if (pingIds.length > 0) {
|
||||
payload.content = pingIds.map((id) => `<@${id}>`).join(" ");
|
||||
payload.allowed_mentions = { parse: [], users: pingIds };
|
||||
}
|
||||
|
||||
await sendDiscordWebhook(webhookUrl, payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const users = pgTable("users", {
|
|||
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
||||
discordPingEnabled: boolean("discord_ping_enabled").notNull().default(false),
|
||||
lastDataRequestAt: timestamp("last_data_request_at"),
|
||||
deletedAt: timestamp("deleted_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
|
|
|
|||
1
drizzle/0105_mighty_talisman.sql
Normal file
1
drizzle/0105_mighty_talisman.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "users" ADD COLUMN "discord_ping_enabled" boolean DEFAULT false NOT NULL;
|
||||
6120
drizzle/meta/0105_snapshot.json
Normal file
6120
drizzle/meta/0105_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -736,6 +736,13 @@
|
|||
"when": 1778784280606,
|
||||
"tag": "0104_chief_boom_boom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 105,
|
||||
"version": "7",
|
||||
"when": 1778862483243,
|
||||
"tag": "0105_mighty_talisman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue