Add Discord notifications for qualifying points updates
Fire a gold-colored Discord embed whenever QP is awarded during a season (tennis/golf majors via processQualifyingEvent, CS2 Swiss stages via assignCs2EliminationQP) so league members see progress throughout the season rather than only when QP finalize into fantasy placements. - discord.ts: QPEventEntry interface + sendQualifyingPointsUpdateNotification (gold embed with Points Awarded, No Points, and QP Standings sections) - qualifying-points-discord.server.ts: notifyQualifyingPointsUpdate — looks up affected leagues, maps drafted participants to teams/owners, assembles entries, and fires the notification per league webhook - scoring-calculator.ts: call notifyQualifyingPointsUpdate at end of processQualifyingEvent (covers tennis, golf, CS2 Champions bracket) - cs2-major-stage.ts: call notifyQualifyingPointsUpdate at end of assignCs2EliminationQP (covers all CS2 Swiss stages incl. 0-QP exits) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnJP1NTYXHxL2kbE4fZaD6
This commit is contained in:
parent
64e5cb23ef
commit
df15386d2d
5 changed files with 450 additions and 1 deletions
|
|
@ -17,6 +17,7 @@ import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/databa
|
|||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
|
||||
import { deleteEventResults } from "~/models/event-result";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
|
||||
export interface Cs2StageResult {
|
||||
id: string;
|
||||
|
|
@ -439,4 +440,6 @@ export async function assignCs2EliminationQP(
|
|||
}
|
||||
|
||||
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
|
||||
|
||||
await notifyQualifyingPointsUpdate(sportsSeasonId, scoringEventId, db).catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
|
||||
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
|
|
@ -958,6 +959,12 @@ export async function processQualifyingEvent(
|
|||
logger.log(
|
||||
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
||||
);
|
||||
|
||||
try {
|
||||
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db);
|
||||
} catch (error) {
|
||||
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord";
|
||||
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } from "../discord";
|
||||
|
||||
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
|
||||
|
||||
|
|
@ -675,6 +675,184 @@ describe("sendDraftOrderNotification", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("sendQualifyingPointsUpdateNotification", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch(204));
|
||||
});
|
||||
|
||||
const BASE_ENTRIES = [
|
||||
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
|
||||
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
|
||||
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
|
||||
];
|
||||
|
||||
it("sends an embed with gold color and QP title", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
|
||||
expect(payload.embeds[0].color).toBe(0xffd700);
|
||||
expect(payload.embeds[0].footer).toEqual({ text: "brackt.com" });
|
||||
});
|
||||
|
||||
it("shows sport and event header", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
sportName: "ATP Tennis",
|
||||
eventName: "Wimbledon 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**ATP Tennis — Wimbledon 2025**");
|
||||
});
|
||||
|
||||
it("shows Points Awarded section for entries with qpEarned > 0, sorted by QP desc", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Points Awarded**");
|
||||
expect(desc).toContain("• **Novak Djokovic (alex)** — +20 QP");
|
||||
expect(desc).toContain("• **Carlos Alcaraz (chris)** — +14 QP");
|
||||
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
|
||||
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
||||
});
|
||||
|
||||
it("shows No Points section for entries with qpEarned == 0", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**No Points**");
|
||||
expect(desc).toContain("Rafael Nadal (alex)");
|
||||
});
|
||||
|
||||
it("shows QP Standings for all entries sorted by qpTotal desc", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**QP Standings**");
|
||||
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
|
||||
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
|
||||
// Djokovic should rank above Alcaraz
|
||||
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
|
||||
});
|
||||
|
||||
it("uses T-prefix for tied QP totals in standings", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("T1\\. Player A");
|
||||
expect(desc).toContain("T1\\. Player B");
|
||||
expect(desc).toContain("3\\. Player C");
|
||||
});
|
||||
|
||||
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 20,
|
||||
qpTotal: 45,
|
||||
ownerUsername: "alex",
|
||||
ownerDiscordUserId: "111222333",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Novak Djokovic (<@111222333>)");
|
||||
expect(desc).not.toContain("(alex)");
|
||||
});
|
||||
|
||||
it("pings opted-in owners who appear in awarded or no-points sections", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
||||
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
||||
],
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.content).toContain("<@111>");
|
||||
expect(payload.content).toContain("<@222>");
|
||||
expect(payload.allowed_mentions.users).toContain("111");
|
||||
expect(payload.allowed_mentions.users).toContain("222");
|
||||
});
|
||||
|
||||
it("does not send when entries array is empty", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escapes markdown in participant names and usernames", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Player\\_One");
|
||||
expect(desc).toContain("user\\_name");
|
||||
});
|
||||
|
||||
it("truncates description at 4096 characters", async () => {
|
||||
const longEntries = Array.from({ length: 200 }, (_, i) => ({
|
||||
participantName: `Very Long Participant Name Number ${i}`,
|
||||
qpEarned: i % 2 === 0 ? 5 : 0,
|
||||
qpTotal: 200 - i,
|
||||
ownerUsername: `owner_with_long_username_${i}`,
|
||||
}));
|
||||
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: longEntries,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc.length).toBeLessThanOrEqual(4096);
|
||||
expect(desc.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPickAnnouncementNotification", () => {
|
||||
const BASE = {
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
|
|
|
|||
|
|
@ -247,6 +247,129 @@ export async function sendStandingsUpdateNotification({
|
|||
await sendDiscordWebhook(webhookUrl, payload);
|
||||
}
|
||||
|
||||
export interface QPEventEntry {
|
||||
participantName: string;
|
||||
qpEarned: number;
|
||||
qpTotal: number;
|
||||
ownerUsername?: string;
|
||||
ownerDiscordUserId?: string;
|
||||
}
|
||||
|
||||
export async function sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl,
|
||||
seasonName,
|
||||
sportName,
|
||||
eventName,
|
||||
entries,
|
||||
}: {
|
||||
webhookUrl: string;
|
||||
seasonName: string;
|
||||
sportName?: string;
|
||||
eventName?: string;
|
||||
entries: QPEventEntry[];
|
||||
}): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (sportName || eventName) {
|
||||
const parts = [sportName, eventName].filter(Boolean);
|
||||
sections.push(`**${parts.join(" — ")}**`);
|
||||
}
|
||||
|
||||
const awardedEntries = entries
|
||||
.filter((e) => e.qpEarned > 0)
|
||||
.toSorted((a, b) => b.qpEarned - a.qpEarned);
|
||||
|
||||
if (awardedEntries.length > 0) {
|
||||
sections.push("\n**Points Awarded**");
|
||||
for (const e of awardedEntries) {
|
||||
const managerLabel = e.ownerDiscordUserId
|
||||
? `<@${e.ownerDiscordUserId}>`
|
||||
: e.ownerUsername
|
||||
? escapeMarkdown(e.ownerUsername)
|
||||
: undefined;
|
||||
const label = managerLabel
|
||||
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
||||
: escapeMarkdown(e.participantName);
|
||||
sections.push(`• **${label}** — +${e.qpEarned} QP`);
|
||||
}
|
||||
}
|
||||
|
||||
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
|
||||
if (zeroEntries.length > 0) {
|
||||
sections.push("\n**No Points**");
|
||||
for (const e of zeroEntries) {
|
||||
const managerLabel = e.ownerDiscordUserId
|
||||
? `<@${e.ownerDiscordUserId}>`
|
||||
: e.ownerUsername
|
||||
? escapeMarkdown(e.ownerUsername)
|
||||
: undefined;
|
||||
const label = managerLabel
|
||||
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
||||
: escapeMarkdown(e.participantName);
|
||||
sections.push(`• ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
|
||||
// Compute ranks with tie detection
|
||||
const ranks: number[] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) {
|
||||
ranks.push(ranks[ranks.length - 1]);
|
||||
} else {
|
||||
ranks.push(i + 1);
|
||||
}
|
||||
}
|
||||
const isTied = buildTiedRankChecker(ranks);
|
||||
|
||||
sections.push("\n**QP Standings**");
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const e = sorted[i];
|
||||
const r = ranks[i];
|
||||
const rankPrefix = isTied(r) ? `T${r}` : `${r}`;
|
||||
const ownerLabel = e.ownerDiscordUserId
|
||||
? `<@${e.ownerDiscordUserId}>`
|
||||
: e.ownerUsername
|
||||
? escapeMarkdown(e.ownerUsername)
|
||||
: undefined;
|
||||
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
|
||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${e.qpTotal} QP`);
|
||||
}
|
||||
|
||||
const MAX_DESCRIPTION = 4096;
|
||||
let description = sections.join("\n");
|
||||
if (description.length > MAX_DESCRIPTION) {
|
||||
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
|
||||
}
|
||||
|
||||
const pingUserIds = new Set<string>();
|
||||
for (const e of [...awardedEntries, ...zeroEntries]) {
|
||||
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
|
||||
}
|
||||
const pingIds = [...pingUserIds];
|
||||
|
||||
const payload: DiscordWebhookPayload = {
|
||||
embeds: [
|
||||
{
|
||||
title: `🏅 Qualifying Points Update — ${seasonName}`,
|
||||
description,
|
||||
color: 0xffd700, // Gold — distinguishable from standings blurple
|
||||
footer: { text: "brackt.com" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (pingIds.length > 0) {
|
||||
const cappedIds = pingIds.slice(0, 100);
|
||||
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
|
||||
payload.allowed_mentions = { parse: [], users: cappedIds };
|
||||
}
|
||||
|
||||
await sendDiscordWebhook(webhookUrl, payload);
|
||||
}
|
||||
|
||||
export async function sendPickAnnouncementNotification({
|
||||
webhookUrl,
|
||||
draftUrl,
|
||||
|
|
|
|||
138
app/services/qualifying-points-discord.server.ts
Normal file
138
app/services/qualifying-points-discord.server.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import {
|
||||
sendQualifyingPointsUpdateNotification,
|
||||
type QPEventEntry,
|
||||
} from "~/services/discord";
|
||||
|
||||
export async function notifyQualifyingPointsUpdate(
|
||||
sportsSeasonId: string,
|
||||
scoringEventId: string,
|
||||
db: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, scoringEventId),
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: { sport: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!event) return;
|
||||
|
||||
const eventName = event.name;
|
||||
const sportName = event.sportsSeason?.sport?.name;
|
||||
|
||||
const seasonSports = await db.query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
if (seasonSports.length === 0) return;
|
||||
|
||||
// QP earned in this specific event
|
||||
const eventResultRows = await db.query.eventResults.findMany({
|
||||
where: eq(schema.eventResults.scoringEventId, scoringEventId),
|
||||
});
|
||||
const qpEarnedById = new Map<string, number>(
|
||||
eventResultRows
|
||||
.filter((r) => r.qualifyingPointsAwarded !== null)
|
||||
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded!)])
|
||||
);
|
||||
|
||||
if (qpEarnedById.size === 0) return;
|
||||
|
||||
// Current running QP totals for the season
|
||||
const qpTotals = await db.query.seasonParticipantQualifyingTotals.findMany({
|
||||
where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
const qpTotalById = new Map<string, number>(
|
||||
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
|
||||
);
|
||||
|
||||
for (const { seasonId } of seasonSports) {
|
||||
const season = await db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, seasonId),
|
||||
with: { league: true },
|
||||
});
|
||||
|
||||
const webhookUrl = season?.league?.discordWebhookUrl;
|
||||
if (!webhookUrl) continue;
|
||||
|
||||
const seasonName = `${season!.league!.name} ${season!.year}`;
|
||||
|
||||
// Map drafted participants → teams in this season
|
||||
const picks = await db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
||||
with: { team: true },
|
||||
});
|
||||
|
||||
const teamByParticipantId = new Map<
|
||||
string,
|
||||
{ teamName: string; ownerId: string | null }
|
||||
>();
|
||||
for (const pick of picks) {
|
||||
teamByParticipantId.set(pick.participantId, {
|
||||
teamName: pick.team.name,
|
||||
ownerId: pick.team.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Only include participants that appear in both the event and this league's draft
|
||||
const relevantParticipantIds = [...qpEarnedById.keys()].filter((id) =>
|
||||
teamByParticipantId.has(id)
|
||||
);
|
||||
if (relevantParticipantIds.length === 0) continue;
|
||||
|
||||
// Participant display names
|
||||
const participants = await db.query.seasonParticipants.findMany({
|
||||
where: inArray(schema.seasonParticipants.id, relevantParticipantIds),
|
||||
});
|
||||
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
|
||||
|
||||
// Owner usernames + Discord IDs
|
||||
const ownerIds = [
|
||||
...new Set(
|
||||
relevantParticipantIds
|
||||
.map((id) => teamByParticipantId.get(id)?.ownerId)
|
||||
.filter((id): id is string => id !== null && id !== undefined)
|
||||
),
|
||||
];
|
||||
|
||||
const users = ownerIds.length
|
||||
? await db.query.users.findMany({
|
||||
where: inArray(schema.users.id, ownerIds),
|
||||
})
|
||||
: [];
|
||||
|
||||
const usernameByUserId = new Map(
|
||||
users
|
||||
.map((u) => [u.id, getUserDisplayName(u)] as [string, string | null])
|
||||
.filter((entry): entry is [string, string] => entry[1] !== null)
|
||||
);
|
||||
|
||||
const optedInUserIds = users.filter((u) => u.discordPingEnabled).map((u) => u.id);
|
||||
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
|
||||
|
||||
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
|
||||
const teamInfo = teamByParticipantId.get(participantId)!;
|
||||
const ownerId = teamInfo.ownerId;
|
||||
return {
|
||||
participantName: participantNameById.get(participantId) ?? participantId,
|
||||
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
||||
qpTotal: qpTotalById.get(participantId) ?? 0,
|
||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl,
|
||||
seasonName,
|
||||
sportName,
|
||||
eventName,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue