diff --git a/example.env b/example.env index 53584b9d..6899d7f8 100644 --- a/example.env +++ b/example.env @@ -8,11 +8,15 @@ BUNGIE_CLIENT_SECRET="" # Required for login # RaidHub API RAIDHUB_API_URL="http://localhost:8000" # Defaults to https://api.raidhub.io when not set RAIDHUB_API_KEY="" # Required for accessing public domain, not required if self-hosting -RAIDHUB_CLIENT_SECRET="" # Required for accessing admin routes, can be set to a string of choice if self-hosting +RAIDHUB_CLIENT_SECRET="" # Admin/internal calls: sent as x-raidhub-client-secret (not in JSON). Match API CLIENT_SECRET if using linked-role sync. # Additional OAuth Providers for account linking # DISCORD_CLIENT_ID="" # DISCORD_CLIENT_SECRET="" +# Same Discord application as OAuth client (for linked-role PUT). Defaults to DISCORD_CLIENT_ID if unset. +# DISCORD_APPLICATION_ID="" +# Metadata key registered in Discord Developer Portal (integer field → string value in API). +# DISCORD_LINKED_ROLES_METADATA_KEY=raidhub_total_clears # TWITCH_CLIENT_ID="" # TWITCH_CLIENT_SECRET="" diff --git a/prisma/migrations/20260503213000_discord_linked_roles_sync/migration.sql b/prisma/migrations/20260503213000_discord_linked_roles_sync/migration.sql new file mode 100644 index 00000000..80941563 --- /dev/null +++ b/prisma/migrations/20260503213000_discord_linked_roles_sync/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "account" ADD COLUMN "discord_role_metadata_synced_at" DATETIME; +ALTER TABLE "account" ADD COLUMN "discord_role_metadata_sync_error" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45d9162c..a7f2268a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,22 +76,26 @@ model Session { } model Account { - id String @id @default(uuid()) - userId String @map("bungie_membership_id") - type String - provider String - providerAccountId String @map("provider_account_id") - displayName String? @map("display_name") - url String? @map("url") - refreshToken String? @map("refresh_token") - accessToken String? @map("access_token") - expiresAt Int? @map("expires_at") - refreshExpiresAt Int? @map("refresh_expires_at") - tokenType String? @map("token_type") - scope String? - idToken String? @map("id_token") - sessionState String? @map("session_state") - user User @relation("UserToAccount", fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String @map("bungie_membership_id") + type String + provider String + providerAccountId String @map("provider_account_id") + displayName String? @map("display_name") + url String? @map("url") + refreshToken String? @map("refresh_token") + accessToken String? @map("access_token") + expiresAt Int? @map("expires_at") + refreshExpiresAt Int? @map("refresh_expires_at") + tokenType String? @map("token_type") + scope String? + idToken String? @map("id_token") + sessionState String? @map("session_state") + /// Last successful push of Discord linked-role metadata (Hermes or BFF). + discordRoleMetadataSyncedAt DateTime? @map("discord_role_metadata_synced_at") + /// Short machine-readable error from last failed push (if any). + discordRoleMetadataSyncError String? @map("discord_role_metadata_sync_error") + user User @relation("UserToAccount", fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId], name: "uniqueProviderAccountId") @@unique([provider, userId], name: "uniqueProviderUser") diff --git a/src/app/account/Client.tsx b/src/app/account/Client.tsx index 90cbf0b6..5e906d43 100644 --- a/src/app/account/Client.tsx +++ b/src/app/account/Client.tsx @@ -1,8 +1,8 @@ "use client" import { Collection } from "@discordjs/collection" +import { AccountPage } from "~/components/account/AccountPage" import { ForceClientSideBungieSignIn } from "~/components/ForceClientSideBungieSignIn" -import Account from "~/components/__deprecated__/account/Account" export const Client = ({ providers @@ -15,13 +15,19 @@ export const Client = ({ }) => ( ( - <> -

Welcome, {session.user.name}

- +
+

Account

+

+ Profiles, profile icon, and linked services for{" "} + {session.user.name}. +

+
+ [p.id, p]))} /> - + )} /> ) diff --git a/src/components/__deprecated__/account/Account.tsx b/src/components/__deprecated__/account/Account.tsx deleted file mode 100644 index dfa821e9..00000000 --- a/src/components/__deprecated__/account/Account.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client" - -import { type Collection } from "@discordjs/collection" -import { type Session } from "next-auth" -import { signIn, signOut } from "next-auth/react" -import Link from "next/link" -import { useMemo, useRef } from "react" -import { DiscordIconOld } from "~/components/icons/DiscordIcon" -import { SpeedrunIcon } from "~/components/icons/SpeedrunIcon" -import TwitchIcon from "~/components/icons/TwitchIcon" -import TwitterIcon from "~/components/icons/TwitterIcon" -import YoutubeIcon from "~/components/icons/YoutubeIcon" -import { trpc } from "~/lib/trpc" -import styles from ".//account.module.css" -import Connection from "./Connection" -import IconUploadForm from "./IconUploadForm" -import SpeedrunAPIKeyModal from "./SpeedrunAPIKeyModal" - -type AccountProps = { - session: Session - providers: Collection< - string, - { - id: string - name: string - type: string - } - > -} - -const bungieMembershipTypeMap = { - "-1": "???", - 0: "???", - 1: "Xbox", - 2: "PSN", - 3: "Steam", - 4: "Blizzard", - 5: "Stadia", - 6: "Epic", - 10: "Demon", - 254: "Bungie.net" -} - -const Account = ({ session, providers }: AccountProps) => { - const { data: socialNames, refetch: refetchSocials } = trpc.user.getConnections.useQuery() - const { mutate: unlinkAccountFromUser } = trpc.user.removeByAccount.useMutation({ - onSuccess() { - void refetchSocials() - } - }) - const { mutate: deleteUserMutation } = trpc.user.delete.useMutation({ - onSuccess() { - window.location.href = "/" - }, - onError(error) { - console.error(error) - alert("An error occurred while deleting your account") - } - }) - const speedrunAPIKeyModalRef = useRef(null) - - const { discordProvider, twitchProvider, twitterProvider, youtubeProvider } = useMemo( - () => ({ - discordProvider: providers?.get("discord"), - twitchProvider: providers?.get("twitch"), - twitterProvider: providers?.get("twitter"), - youtubeProvider: providers?.get("youtube") - }), - [providers] - ) - - return ( - <> - -
-
- {session?.user.profiles.map(profile => ( - - - - ))} - - - -
-
-
-

Manage Account

- -
-
-

Manage Connections

-
- {discordProvider && ( - unlinkAccountFromUser({ providerId: "discord" })} - link={() => signIn("discord", {}, { prompt: "consent" })} - serviceName={discordProvider.name} - username={socialNames?.get("discord") ?? null} - Icon={DiscordIconOld} - /> - )} - {twitterProvider && ( - unlinkAccountFromUser({ providerId: "twitter" })} - link={() => signIn("twitter", {}, { force_login: "true" })} - serviceName={twitterProvider.name} - username={socialNames?.get("twitter") ?? null} - Icon={TwitterIcon} - /> - )} - {twitchProvider && ( - unlinkAccountFromUser({ providerId: "twitch" })} - link={() => signIn("twitch", {}, { force_verify: "true" })} - serviceName={twitchProvider.name} - username={socialNames?.get("twitch") ?? null} - Icon={TwitchIcon} - /> - )} - {youtubeProvider && ( - unlinkAccountFromUser({ providerId: "youtube" })} - link={() => signIn("youtube", {}, { prompt: "select_account" })} - serviceName={youtubeProvider.name} - username={socialNames?.get("youtube") ?? null} - Icon={YoutubeIcon} - /> - )} - unlinkAccountFromUser({ providerId: "speedrun" })} - link={() => speedrunAPIKeyModalRef.current?.showModal()} - serviceName="Speedrun.com" - username={socialNames?.get("speedrun") ?? null} - Icon={props => } - /> -
-
- - ) -} - -export default Account diff --git a/src/components/__deprecated__/account/Connection.tsx b/src/components/__deprecated__/account/Connection.tsx deleted file mode 100644 index 4c96feb9..00000000 --- a/src/components/__deprecated__/account/Connection.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { type SVGComponent } from "~/components/SVG" -import styles from "./account.module.css" - -export default function Connection({ - unlink, - link, - serviceName, - username, - Icon -}: { - username: string | null - serviceName: string - link: () => void - unlink: () => void - Icon: SVGComponent -}) { - const canLink = !username - - return ( -
-
-

{serviceName}

- {username} -
- -
-
-
- - -
-
- ) -} diff --git a/src/components/__deprecated__/account/IconUploadForm.tsx b/src/components/__deprecated__/account/IconUploadForm.tsx deleted file mode 100644 index d91adba1..00000000 --- a/src/components/__deprecated__/account/IconUploadForm.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client" - -import Image from "next/image" -import { useState, type ChangeEventHandler } from "react" -import { useForm, type SubmitHandler } from "react-hook-form" -import { useSession } from "~/hooks/app/useSession" -import { trpc } from "~/lib/trpc" -import { uploadProfileIcon } from "~/services/s3/uploadProfileIcon" -import styles from "./account.module.css" - -type FormValues = { - image: File -} - -const IconUploadForm = () => { - const { data: session, update: updateSession } = useSession() - const [imageSrc, setImageSrc] = useState(null) - const [err, setErr] = useState(null) - const { mutateAsync: createPresignedURL } = trpc.user.generatePresignedIconURL.useMutation() - const { - mutate: optimisticProfileUpdate, - isLoading, - error - } = trpc.user.update.useMutation({ - onSuccess: () => { - void updateSession() - alert("Icon updated") - } - }) - - const { handleSubmit, setValue, resetField } = useForm({ - defaultValues: {} - }) - - const onSubmit: SubmitHandler = async data => { - try { - const primaryProfile = session?.user.profiles.find( - p => p.destinyMembershipId === session.primaryDestinyMembershipId - ) - if (data && primaryProfile) { - const fileType = data.image.type - if (!fileType) { - setErr(new Error("Please try again")) - return - } - - const signedURL = await createPresignedURL({ fileType: fileType }) - - const successfulUpload = await uploadProfileIcon(data.image, signedURL) - if (!successfulUpload) { - setErr(new Error("Failed to upload Image")) - return - } - - const newIconUrl = signedURL.url + signedURL.fields.key - - optimisticProfileUpdate({ - data: { - image: newIconUrl - }, - destinyMembershipId: primaryProfile.destinyMembershipId - }) - } else { - setErr(new Error("Please try again")) - } - } catch (e) { - console.error(e) - } - } - - const handleFileChange: ChangeEventHandler = event => { - const file = event.target.files?.[0] - if (file) { - if (file.size > 256_000 /** 250 KB */) { - setErr(new Error("File too large. Max: 256kb")) - resetField("image") - setImageSrc(null) - return - } - setValue("image", file) - setImageSrc(URL.createObjectURL(file)) - } - } - - return ( -
-
- {imageSrc && selected icon} -
- {" "} - -
-
- {err &&
{err.message}
} - {error &&
{error.message}
} - -
- ) -} - -export default IconUploadForm diff --git a/src/components/__deprecated__/account/SpeedrunAPIKeyModal.tsx b/src/components/__deprecated__/account/SpeedrunAPIKeyModal.tsx deleted file mode 100644 index c5eec9e9..00000000 --- a/src/components/__deprecated__/account/SpeedrunAPIKeyModal.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import Link from "next/link" -import React from "react" -import { useForm, type SubmitHandler } from "react-hook-form" -import { z } from "zod" -import { trpc } from "~/lib/trpc" -import styles from "./account.module.css" - -const errMsg = "Invalid API Key format: " -const zFormSchema = z.object({ - apiKey: z - .string() - .min(20, { message: errMsg + "too few characters" }) - .max(30, { message: errMsg + "too many characters" }) -}) - -type FormSchemaType = z.infer - -export default React.forwardRef void }>( - function SpeedrunAPIKeyModal({ refetchSocials }, ref) { - const closeModal = () => { - if (typeof ref === "object") { - ref?.current?.close() - } - } - const { - mutate: updateAPIKey, - isError, - error, - isLoading - } = trpc.user.createSpeedrunComAccount.useMutation({ - onSuccess() { - closeModal() - refetchSocials() - reset() - } - }) - const { - handleSubmit, - register, - formState: { errors }, - reset - } = useForm({ - resolver: zodResolver(zFormSchema) - }) - - const onSubmit: SubmitHandler = data => { - updateAPIKey(data) - } - - const err = isError ? error : errors.apiKey - - return ( - - -

Connect with Speedrun.com

-

- In order to authenticate with speedrun.com, you must paste your secret API key - into the text box below. You can access this key at{" "} - - speedrun.com/settings/api - -

-

- We will not ask for your username or password, though you might be prompted to - log in or create an account on speedrun.com if you are not logged in already. -

-

Full steps:

-
    -
  1. - Login to{" "} - - www.speedrun.com - -
  2. -
  3. Click on your user icon in the top right corner
  4. -
  5. - Select Settings in the drop down -
  6. -
  7. - Scroll down to the panel labeled Developers -
  8. -
  9. - Click API Key -
  10. -
  11. - Click Show API Key -
  12. -
  13. Copy the key
  14. -
  15. Paste the key into the text box on this page
  16. -
  17. - Press Submit -
  18. -
-

- We do not store your API key on our servers. We only use it to verify that you - own the account you are linking, and then the key is discarded. If you like, you - may click Regenerate next to your API key on speedrun.com to take extra - precaution. -

- -
- - - {err &&
{err.message}
} -
-
- ) - } -) diff --git a/src/components/__deprecated__/account/account.module.css b/src/components/__deprecated__/account/account.module.css deleted file mode 100644 index 9079d21e..00000000 --- a/src/components/__deprecated__/account/account.module.css +++ /dev/null @@ -1,136 +0,0 @@ -.section { - margin-bottom: 1em; -} - -.flex { - display: flex; - flex-direction: column; - flex-wrap: wrap; - align-content: flex-start; -} - -.buttons { - display: flex; - gap: 1em; - - flex-wrap: wrap; -} - -.glossy-bg { - border-radius: 10px; - padding: 2em; - background-color: #1a191941; -} - -.buttons button, -.form button { - border-radius: 15px; - border: none; - - font-weight: 800; - - text-transform: uppercase; - padding: 10px; - transition: background-color 0.2s ease-out; - cursor: pointer; -} - -.buttons button:hover:not(:disabled) { - background-color: #ed904e; - border-radius: 15px; - border: none; - - text-transform: uppercase; - padding: 10px; -} - -.destructive { - color: white; - background-color: rgb(225, 51, 51); -} - -.form { - display: flex; - flex-direction: row; - gap: 2em; - flex-wrap: wrap; -} -.form-element { - display: flex; - flex-direction: row; - - gap: 1em; -} -.form-element > div { - display: flex; - flex-direction: column; -} -.form button { - align-self: center; -} -.connections { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 450px)); - - gap: 2em; -} - -.connection-head { - display: flex; - flex-direction: row; - justify-content: flex-start; - - gap: 1em; - margin-bottom: 1em; -} -.connection-head h3 { - margin: 0; -} - -.social-icon-container { - margin-left: auto; -} - -.api-key-modal { - position: fixed; - z-index: 10; - - background-color: #1a191941; - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - - max-width: 700px; -} - -.api-key-modal button { - cursor: pointer; -} - -.api-key-modal-close-button { - position: absolute; - top: 0.7em; - right: 0.7em; -} - -.api-key-modal li { - padding: 0.5em; -} - -.api-key-modal a { - color: #ed904e; -} - -.api-key-modal em { - font-weight: 500; -} - -.api-key-modal form { - display: flex; - flex-direction: row; - gap: 1em; - flex-wrap: wrap; -} - -.api-key-modal-err { - color: red; -} diff --git a/src/components/account/AccountConnectionCard.tsx b/src/components/account/AccountConnectionCard.tsx new file mode 100644 index 00000000..85f9a176 --- /dev/null +++ b/src/components/account/AccountConnectionCard.tsx @@ -0,0 +1,71 @@ +"use client" + +import type { ReactNode } from "react" +import { type SVGComponent } from "~/components/SVG" +import { Button } from "~/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card" +import { cn } from "~/lib/tw" + +type AccountConnectionCardProps = { + serviceName: string + username: string | null + link: () => void + unlink: () => void + Icon: SVGComponent + footer?: ReactNode +} + +export function AccountConnectionCard({ + serviceName, + username, + link, + unlink, + Icon, + footer +}: AccountConnectionCardProps) { + const linked = Boolean(username) + + return ( + + +
+ +
+
+ {serviceName} + + {linked ? ( + <> + Linked as{" "} + {username} + + ) : ( + "Not connected" + )} + +
+
+ +
+ + +
+ {footer} +
+
+ ) +} diff --git a/src/components/account/AccountPage.tsx b/src/components/account/AccountPage.tsx new file mode 100644 index 00000000..b71464c4 --- /dev/null +++ b/src/components/account/AccountPage.tsx @@ -0,0 +1,258 @@ +"use client" + +import { type Collection } from "@discordjs/collection" +import { type Session } from "next-auth" +import { signIn, signOut } from "next-auth/react" +import Link from "next/link" +import { useMemo, useRef } from "react" +import { DiscordIconOld } from "~/components/icons/DiscordIcon" +import { SpeedrunIcon } from "~/components/icons/SpeedrunIcon" +import TwitchIcon from "~/components/icons/TwitchIcon" +import TwitterIcon from "~/components/icons/TwitterIcon" +import YoutubeIcon from "~/components/icons/YoutubeIcon" +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" +import { Badge } from "~/components/ui/badge" +import { Button } from "~/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card" +import { Separator } from "~/components/ui/separator" +import { trpc } from "~/lib/trpc" +import { AccountConnectionCard } from "./AccountConnectionCard" +import { DiscordLinkedRolesPanel } from "./DiscordLinkedRolesPanel" +import { ProfileIconForm } from "./ProfileIconForm" +import { SpeedrunAPIKeyDialog } from "./SpeedrunAPIKeyDialog" + +const bungieMembershipTypeLabel: Record = { + [-1]: "Unknown", + 0: "Unknown", + 1: "Xbox", + 2: "PSN", + 3: "Steam", + 4: "Battle.net", + 5: "Stadia", + 6: "Epic", + 10: "Demon", + 254: "Bungie.net" +} + +type AccountPageProps = { + session: Session + providers: Collection< + string, + { + id: string + name: string + type: string + } + > +} + +export function AccountPage({ session, providers }: AccountPageProps) { + const utils = trpc.useUtils() + const speedrunDialogRef = useRef(null) + const { data: socialNames, refetch: refetchSocials } = trpc.user.getConnections.useQuery() + + const refreshConnections = () => { + void refetchSocials() + void utils.user.discordLinkedRolesStatus.invalidate() + } + + const { mutate: unlinkAccountFromUser } = trpc.user.removeByAccount.useMutation({ + onSuccess: refreshConnections + }) + + const { mutate: deleteUserMutation } = trpc.user.delete.useMutation({ + onSuccess() { + window.location.href = "/" + }, + onError(error) { + console.error(error) + window.alert("An error occurred while deleting your account") + } + }) + + const { discordProvider, twitchProvider, twitterProvider, youtubeProvider } = useMemo( + () => ({ + discordProvider: providers.get("discord"), + twitchProvider: providers.get("twitch"), + twitterProvider: providers.get("twitter"), + youtubeProvider: providers.get("youtube") + }), + [providers] + ) + + const initial = session.user.name?.trim().charAt(0).toUpperCase() ?? "?" + + return ( +
+ + + + +
+ + {session.user.image ? ( + + ) : null} + + {initial} + + +
+
+ + {session.user.name} + + + Signed in with Bungie. Open a profile, tweak your icon, and link + social accounts below. + +
+
+ {session.user.profiles.map(profile => { + const label = + bungieMembershipTypeLabel[profile.destinyMembershipType] ?? + "Profile" + return ( + + ) + })} +
+
+ + +
+
+
+
+
+ +
+
+

Profile icon

+

+ Shown on RaidHub for your primary Destiny profile. +

+
+ +
+ +
+
+

Linked accounts

+

+ Connect services for your profile. For Discord role sync, use{" "} + Connect and approve{" "} + role_connections.write when + prompted. +

+
+
+ {discordProvider ? ( + unlinkAccountFromUser({ providerId: "discord" })} + link={() => signIn("discord", {}, { prompt: "consent" })} + serviceName={discordProvider.name} + username={socialNames?.get("discord") ?? null} + Icon={DiscordIconOld} + footer={} + /> + ) : null} + {twitterProvider ? ( + unlinkAccountFromUser({ providerId: "twitter" })} + link={() => signIn("twitter", {}, { force_login: "true" })} + serviceName={twitterProvider.name} + username={socialNames?.get("twitter") ?? null} + Icon={TwitterIcon} + /> + ) : null} + {twitchProvider ? ( + unlinkAccountFromUser({ providerId: "twitch" })} + link={() => signIn("twitch", {}, { force_verify: "true" })} + serviceName={twitchProvider.name} + username={socialNames?.get("twitch") ?? null} + Icon={TwitchIcon} + /> + ) : null} + {youtubeProvider ? ( + unlinkAccountFromUser({ providerId: "youtube" })} + link={() => signIn("youtube", {}, { prompt: "select_account" })} + serviceName={youtubeProvider.name} + username={socialNames?.get("youtube") ?? null} + Icon={YoutubeIcon} + /> + ) : null} + unlinkAccountFromUser({ providerId: "speedrun" })} + link={() => speedrunDialogRef.current?.showModal()} + serviceName="Speedrun.com" + username={socialNames?.get("speedrun") ?? null} + Icon={props => } + /> +
+
+ + + +
+
+

+ Danger zone +

+

+ Permanently delete your RaidHub account and associated data. This cannot be + undone. +

+
+ + +

Delete your RaidHub account

+ +
+
+
+
+ ) +} diff --git a/src/components/account/DiscordLinkedRolesPanel.tsx b/src/components/account/DiscordLinkedRolesPanel.tsx new file mode 100644 index 00000000..f7bd3282 --- /dev/null +++ b/src/components/account/DiscordLinkedRolesPanel.tsx @@ -0,0 +1,117 @@ +"use client" + +import { Button } from "~/components/ui/button" +import { trpc } from "~/lib/trpc" +import { cn } from "~/lib/tw" +import type { DiscordLinkedRoleSyncHealth } from "~/types/api" + +type DiscordLinkedRolesPanelProps = { + /** Nested under the Discord connection card (no duplicate outer chrome). */ + variant?: "standalone" | "embedded" +} + +function syncHealthBanner( + health: DiscordLinkedRoleSyncHealth +): { tone: "muted" | "amber" | "destructive"; text: string } | null { + switch (health) { + case "not_linked": + return null + case "needs_scope": + return { + tone: "amber", + text: "Reconnect Discord above and include consent for linked roles (scope role_connections.write)." + } + case "needs_reconnect": + return { + tone: "amber", + text: "Discord rejected the last metadata update. Disconnect and reconnect Discord above, then try Sync now." + } + case "pending": + return { + tone: "muted", + text: "RaidHub has not recorded a successful push yet. After your next qualifying raid completes—or if you use Sync now—status should update here when the worker finishes." + } + case "ok": + return { + tone: "muted", + text: "RaidHub last pushed your stats to Discord successfully. Each server applies linked roles on its own schedule; allow a few minutes before expecting a role change." + } + case "error": + return { + tone: "destructive", + text: "The last push did not succeed. Try Sync now. If it keeps failing, try reconnecting Discord or check back later." + } + default: + return null + } +} + +export function DiscordLinkedRolesPanel({ variant = "standalone" }: DiscordLinkedRolesPanelProps) { + const { data, refetch, isLoading } = trpc.user.discordLinkedRolesStatus.useQuery() + const push = trpc.user.pushDiscordLinkedRoles.useMutation({ + onSuccess() { + void refetch() + } + }) + + if (isLoading || !data?.linked) { + return null + } + + const embedded = variant === "embedded" + const banner = syncHealthBanner(data.syncHealth) + const bannerClass = + banner?.tone === "destructive" + ? "text-destructive" + : banner?.tone === "amber" + ? "text-amber-300/90" + : "text-muted-foreground" + + return ( +
+

+ Discord linked roles +

+

+ RaidHub sends your linked-role metadata (for example clear totals) to Discord. + Server admins map those fields to roles in Discord; RaidHub does not assign Discord + roles directly. +

+ {banner ? ( +

{banner.text}

+ ) : null} + {data.lastSyncedAt ? ( +

+ Last synced: {new Date(data.lastSyncedAt).toLocaleString()} +

+ ) : null} + {data.lastError ? ( +

Error code: {data.lastError}

+ ) : null} +
+ +
+ {push.data && !push.data.ok ? ( +

+ Sync failed: {push.data.code} + {push.data.code === "enqueue_failed" + ? " — the queue may be full. Try again in a few minutes." + : push.data.detail + ? ` — ${push.data.detail}` + : ""} +

+ ) : null} +
+ ) +} diff --git a/src/components/account/ProfileIconForm.tsx b/src/components/account/ProfileIconForm.tsx new file mode 100644 index 00000000..dcd07810 --- /dev/null +++ b/src/components/account/ProfileIconForm.tsx @@ -0,0 +1,134 @@ +"use client" + +import { useState, type ChangeEventHandler } from "react" +import { useForm, type SubmitHandler } from "react-hook-form" +import { toast } from "sonner" +import { Button } from "~/components/ui/button" +import { Card, CardContent } from "~/components/ui/card" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" +import { useSession } from "~/hooks/app/useSession" +import { trpc } from "~/lib/trpc" +import { uploadProfileIcon } from "~/services/s3/uploadProfileIcon" + +type FormValues = { + image: File +} + +export function ProfileIconForm() { + const { data: session, update: updateSession } = useSession() + const [imageSrc, setImageSrc] = useState(null) + const [err, setErr] = useState(null) + const { mutateAsync: createPresignedURL } = trpc.user.generatePresignedIconURL.useMutation() + const { + mutate: optimisticProfileUpdate, + isLoading, + error + } = trpc.user.update.useMutation({ + onSuccess: () => { + void updateSession() + toast.success("Profile icon updated") + } + }) + + const { handleSubmit, setValue, resetField } = useForm({ + defaultValues: {} + }) + + const onSubmit: SubmitHandler = async data => { + try { + const primaryProfile = session?.user.profiles.find( + p => p.destinyMembershipId === session.primaryDestinyMembershipId + ) + if (data && primaryProfile) { + const fileType = data.image.type + if (!fileType) { + setErr(new Error("Please try again")) + return + } + + const signedURL = await createPresignedURL({ fileType: fileType }) + + const successfulUpload = await uploadProfileIcon(data.image, signedURL) + if (!successfulUpload) { + setErr(new Error("Failed to upload image")) + return + } + + const newIconUrl = signedURL.url + signedURL.fields.key + + optimisticProfileUpdate({ + data: { + image: newIconUrl + }, + destinyMembershipId: primaryProfile.destinyMembershipId + }) + } else { + setErr(new Error("Please try again")) + } + } catch (e) { + console.error(e) + } + } + + const handleFileChange: ChangeEventHandler = event => { + const file = event.target.files?.[0] + if (file) { + if (file.size > 256_000) { + setErr(new Error("File too large. Maximum size is 256 KB.")) + resetField("image") + setImageSrc(null) + return + } + setErr(null) + setValue("image", file) + setImageSrc(URL.createObjectURL(file)) + } + } + + return ( + + +
+ {imageSrc ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- local object URL preview */} + Selected icon preview +
+ ) : null} +
+ + +
+ +
+ {err ? ( +

+ {err.message} +

+ ) : null} + {error ? ( +

+ {error.message} +

+ ) : null} +
+
+ ) +} diff --git a/src/components/account/SpeedrunAPIKeyDialog.tsx b/src/components/account/SpeedrunAPIKeyDialog.tsx new file mode 100644 index 00000000..1e758777 --- /dev/null +++ b/src/components/account/SpeedrunAPIKeyDialog.tsx @@ -0,0 +1,136 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import Link from "next/link" +import React from "react" +import { useForm, type SubmitHandler } from "react-hook-form" +import { z } from "zod" +import { Button } from "~/components/ui/button" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" +import { trpc } from "~/lib/trpc" + +const errMsg = "Invalid API key format: " +const zFormSchema = z.object({ + apiKey: z + .string() + .min(20, { message: errMsg + "too few characters" }) + .max(30, { message: errMsg + "too many characters" }) +}) + +type FormSchemaType = z.infer + +export const SpeedrunAPIKeyDialog = React.forwardRef< + HTMLDialogElement, + { refetchSocials: () => void } +>(function SpeedrunAPIKeyDialog({ refetchSocials }, ref) { + const closeModal = () => { + if (typeof ref === "object") { + ref?.current?.close() + } + } + const { + mutate: updateAPIKey, + isError, + error, + isLoading + } = trpc.user.createSpeedrunComAccount.useMutation({ + onSuccess() { + closeModal() + refetchSocials() + reset() + } + }) + const { + handleSubmit, + register, + formState: { errors }, + reset + } = useForm({ + resolver: zodResolver(zFormSchema) + }) + + const onSubmit: SubmitHandler = data => { + updateAPIKey(data) + } + + const err = isError ? error : errors.apiKey + + return ( + + +

Connect Speedrun.com

+
+

+ Paste your secret API key below. You can find it at{" "} + + speedrun.com/settings/api + + . +

+

+ We will not ask for your username or password. We only use the key once to + verify you own the account, then discard it. +

+

Steps

+
    +
  1. + Log in to{" "} + + speedrun.com + +
  2. +
  3. Open your user menu → Settings
  4. +
  5. Under Developers, open API Key → Show API Key
  6. +
  7. Copy the key and paste it here, then submit
  8. +
+

+ You may regenerate the key on speedrun.com afterward if you prefer. We do not + store the key on our servers after verification. +

+
+
+
+ + +
+ +
+ {err ? ( +

+ {"message" in err && typeof err.message === "string" + ? err.message + : "Request failed"} +

+ ) : null} +
+ ) +}) diff --git a/src/lib/server/auth/authEvents.ts b/src/lib/server/auth/authEvents.ts new file mode 100644 index 00000000..92790d87 --- /dev/null +++ b/src/lib/server/auth/authEvents.ts @@ -0,0 +1,25 @@ +import "server-only" + +import type { AdapterUser } from "@auth/core/adapters" +import type { Account, User } from "@auth/core/types" +import { pushLinkedRoleMetadataForUser } from "~/lib/server/discord/pushLinkedRoleMetadata" + +/** + * After Discord is linked to a Bungie user, push application role connection metadata once + * so linked roles can evaluate without waiting for a raid completion or manual Sync. + */ +export const authEvents = { + async linkAccount(message: { user: User | AdapterUser; account: Account }) { + if (message.account.provider !== "discord") { + return + } + const bungieMembershipId = message.user.id + if (typeof bungieMembershipId !== "string" || bungieMembershipId.length === 0) { + return + } + const result = await pushLinkedRoleMetadataForUser(bungieMembershipId) + if (!result.ok) { + console.warn("[authEvents.linkAccount] pushLinkedRoleMetadataForUser", result) + } + } +} diff --git a/src/lib/server/auth/discordTokenRefresh.ts b/src/lib/server/auth/discordTokenRefresh.ts new file mode 100644 index 00000000..44b26b00 --- /dev/null +++ b/src/lib/server/auth/discordTokenRefresh.ts @@ -0,0 +1,118 @@ +import "server-only" + +import { prisma } from "~/lib/server/prisma" +import { saferFetch } from "~/lib/server/saferFetch" + +type DiscordTokenResponse = { + access_token: string + refresh_token?: string + expires_in: number + scope?: string + token_type?: string +} + +const refreshInflight = new Map>() + +function needsAccessRefresh( + expiresAt: number | null, + accessToken: string | null, + nowSec: number, + skewSec: number +): boolean { + if (!accessToken) return true + if (expiresAt == null) return true + return expiresAt - skewSec <= nowSec +} + +async function runRefreshDiscordAccountTokensIfNeeded( + bungieMembershipId: string +): Promise { + const account = await prisma.account.findFirst({ + where: { userId: bungieMembershipId, provider: "discord" }, + select: { + refreshToken: true, + accessToken: true, + expiresAt: true + } + }) + if (!account) { + return true + } + + const nowSec = Math.floor(Date.now() / 1000) + const skew = 300 + + if (!needsAccessRefresh(account.expiresAt, account.accessToken, nowSec, skew)) { + return true + } + + if (!account.refreshToken) { + return false + } + + const clientId = process.env.DISCORD_CLIENT_ID + const clientSecret = process.env.DISCORD_CLIENT_SECRET + if (!clientId || !clientSecret) { + return false + } + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: account.refreshToken + }) + + const res = await saferFetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }) + + const raw = (await res.json()) as DiscordTokenResponse & { + error?: string + error_description?: string + } + if (!res.ok) { + const code = typeof raw.error === "string" ? raw.error : "unknown" + console.warn("[DISCORD_TOKEN_REFRESH_HTTP_ERROR]", { status: res.status, error_code: code }) + return false + } + + const expiresAt = Math.floor(Date.now() / 1000) + (raw.expires_in ?? 604800) + + try { + await prisma.account.updateMany({ + where: { userId: bungieMembershipId, provider: "discord" }, + data: { + accessToken: raw.access_token, + refreshToken: raw.refresh_token ?? account.refreshToken, + expiresAt, + scope: raw.scope ?? undefined, + tokenType: raw.token_type ?? "bearer" + } + }) + } catch (e) { + console.error( + "[DISCORD_TOKEN_REFRESH_PERSIST_FAILED]", + e instanceof Error ? e.message : String(e) + ) + return false + } + return true +} + +/** Refreshes the Discord OAuth row for this Bungie user when near expiry. Returns false if a refresh was required but could not be completed. */ +export async function refreshDiscordAccountTokensIfNeeded( + bungieMembershipId: string +): Promise { + const existing = refreshInflight.get(bungieMembershipId) + if (existing) { + return existing + } + const p = runRefreshDiscordAccountTokensIfNeeded(bungieMembershipId).finally(() => { + refreshInflight.delete(bungieMembershipId) + }) + refreshInflight.set(bungieMembershipId, p) + return p +} diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index d7702732..4e75ba05 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -8,6 +8,7 @@ import TwitterProvider from "next-auth/providers/twitter" import { prisma } from "~/lib/server/prisma" import { reactRequestDedupe } from "~/util/react-cache" import { PrismaAdapter } from "./adapter" +import { authEvents } from "./authEvents" import BungieProvider from "./providers/bungie" import { YouTubeProvider } from "./providers/youtube" import { sessionCallback } from "./sessionCallback" @@ -35,6 +36,7 @@ const { session: sessionCallback, signIn: signInCallback }, + events: authEvents, logger: { error(err) { console.error(err) @@ -80,7 +82,8 @@ export function getProviders(): ProviderType[] { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET, // removes the email scope - authorization: "https://discord.com/api/oauth2/authorize?scope=identify" + authorization: + "https://discord.com/api/oauth2/authorize?scope=identify%20role_connections.write" }) providers.push(discordProvider) } diff --git a/src/lib/server/auth/sessionCallback.ts b/src/lib/server/auth/sessionCallback.ts index 03bac322..5a3a90db 100644 --- a/src/lib/server/auth/sessionCallback.ts +++ b/src/lib/server/auth/sessionCallback.ts @@ -7,6 +7,7 @@ import { prisma } from "~/lib/server/prisma" import { BungieServiceError } from "~/models/BungieAPIError" import ServerBungieClient from "~/services/bungie/ServerBungieClient" import { postRaidHubApi } from "~/services/raidhub/common" +import { refreshDiscordAccountTokensIfNeeded } from "./discordTokenRefresh" import { type AuthError, type BungieAccount } from "./types" import { updateBungieAccessTokens } from "./updateBungieAccessTokens" @@ -16,22 +17,29 @@ export const sessionCallback = (async ({ session, user: { raidHubAccessToken, bungieAccount, ...user } }: NonNullable["getSessionAndUser"]>>>) => { - const [bungieToken, raidhubToken] = await Promise.all([ + const [bungieToken, raidhubToken, discordRefreshOk] = await Promise.all([ refreshBungieAuth(bungieAccount, user.id), refreshRaidHubBearer({ userId: user.id, token: raidHubAccessToken, role: user.role, profiles: user.profiles - }) + }), + refreshDiscordAccountTokensIfNeeded(user.id) ]) + const errors: AuthError[] = [ + ...(raidhubToken?.errors ?? []), + ...bungieToken.errors, + ...(discordRefreshOk ? [] : (["DiscordTokenRefreshError"] as const)) + ] + return { user, primaryDestinyMembershipId: user.profiles.find(p => p.isPrimary)?.destinyMembershipId, bungieAccessToken: bungieToken.token, raidHubAccessToken: raidhubToken?.token ?? undefined, - errors: Array.from(new Set([...(raidhubToken?.errors ?? []), ...bungieToken.errors])), + errors: Array.from(new Set(errors)), expires: session.expires } }) as unknown as Required["callbacks"]["session"] diff --git a/src/lib/server/auth/types.ts b/src/lib/server/auth/types.ts index 3807c418..975aa70b 100644 --- a/src/lib/server/auth/types.ts +++ b/src/lib/server/auth/types.ts @@ -75,5 +75,7 @@ export type AuthError = | "BungieAccessTokenError" | "BungieAPIOffline" | "ExpiredBungieRefreshToken" + | "ExpiredRefreshTokenError" | "RaidHubAuthorizationError" | "PrismaError" + | "DiscordTokenRefreshError" diff --git a/src/lib/server/discord/linkedRoleSyncError.ts b/src/lib/server/discord/linkedRoleSyncError.ts new file mode 100644 index 00000000..3249f5a3 --- /dev/null +++ b/src/lib/server/discord/linkedRoleSyncError.ts @@ -0,0 +1,19 @@ +import "server-only" + +/** Only expose stable worker-written codes to the client (avoid leaking HTTP bodies or stack text from Turso). */ +export function sanitizeLinkedRoleSyncErrorCode(raw: string | null | undefined): string | null { + if (raw == null) { + return null + } + const t = raw.trim() + if (t.length === 0) { + return null + } + if (t.length > 64) { + return "sync_error" + } + if (!/^[\w.-]+$/.test(t)) { + return "sync_error" + } + return t +} diff --git a/src/lib/server/discord/pushLinkedRoleMetadata.ts b/src/lib/server/discord/pushLinkedRoleMetadata.ts new file mode 100644 index 00000000..4ad0c178 --- /dev/null +++ b/src/lib/server/discord/pushLinkedRoleMetadata.ts @@ -0,0 +1,76 @@ +import "server-only" + +import { refreshDiscordAccountTokensIfNeeded } from "~/lib/server/auth/discordTokenRefresh" +import { prisma } from "~/lib/server/prisma" +import { postRaidHubApi } from "~/services/raidhub/common" +import { RAIDHUB_INTERNAL_PATHS } from "~/services/raidhub/internalPaths" +import { getRaidHubErrorEnvelopeMessage, RaidHubError } from "~/services/raidhub/RaidHubError" + +export type PushLinkedRoleMetadataResult = + | { ok: true } + | { + ok: false + code: "not_linked" | "missing_env" | "refresh_failed" | "no_profile" | "enqueue_failed" + detail?: string + } + +/** Validates Discord link, loads all Destiny profiles in Prisma, refreshes OAuth, then enqueues sync via api.raidhub.io → Rabbit → Hermes. */ +export async function pushLinkedRoleMetadataForUser( + bungieMembershipId: string +): Promise { + const apiUrl = process.env.RAIDHUB_API_URL?.trim() + const clientSecret = process.env.RAIDHUB_CLIENT_SECRET?.trim() + if (!apiUrl || !clientSecret) { + return { + ok: false, + code: "missing_env", + detail: "RAIDHUB_API_URL and RAIDHUB_CLIENT_SECRET (same value API uses as CLIENT_SECRET)" + } + } + + const discordRow = await prisma.account.findFirst({ + where: { userId: bungieMembershipId, provider: "discord" }, + select: { accessToken: true, scope: true } + }) + if (!discordRow?.accessToken) { + return { ok: false, code: "not_linked" } + } + if (!discordRow.scope?.includes("role_connections.write")) { + return { ok: false, code: "not_linked", detail: "reconnect_discord" } + } + + const profiles = await prisma.profile.findMany({ + where: { bungieMembershipId }, + select: { destinyMembershipId: true } + }) + if (profiles.length === 0) { + return { ok: false, code: "no_profile" } + } + const destinyMembershipIds = profiles.map(p => String(p.destinyMembershipId)) + + const refreshed = await refreshDiscordAccountTokensIfNeeded(bungieMembershipId) + if (!refreshed) { + return { ok: false, code: "refresh_failed" } + } + + try { + await postRaidHubApi( + RAIDHUB_INTERNAL_PATHS.queueDiscordLinkedRoleSync, + "post", + { destinyMembershipIds }, + null, + undefined, + { headers: { "x-raidhub-client-secret": clientSecret } } + ) + return { ok: true } + } catch (e) { + if (e instanceof RaidHubError && e.errorCode === "ServiceUnavailableError") { + return { ok: false, code: "enqueue_failed", detail: getRaidHubErrorEnvelopeMessage(e) } + } + return { + ok: false, + code: "enqueue_failed", + detail: "request_failed" + } + } +} diff --git a/src/lib/server/trpc/error-handler.ts b/src/lib/server/trpc/error-handler.ts index 3716d447..cc83b908 100644 --- a/src/lib/server/trpc/error-handler.ts +++ b/src/lib/server/trpc/error-handler.ts @@ -1,11 +1,8 @@ import type { ProcedureType, TRPCError } from "@trpc/server" -import { DiscordColors, sendDiscordWebhook } from "~/services/discord/webhook" export const trpcErrorHandler = async ({ error, - path, - input, - source + path }: { error: TRPCError type: ProcedureType | "unknown" @@ -14,54 +11,4 @@ export const trpcErrorHandler = async ({ source: "rpc" | "http" }) => { console.error(`❌ tRPC failed on ${path ?? ""}:`, error) - - if (process.env.NODE_ENV === "production" && process.env.TRPC_ALERTS_WEBHOOK_URL) { - await sendDiscordWebhook(process.env.TRPC_ALERTS_WEBHOOK_URL, { - embeds: [ - { - color: DiscordColors.RED, - fields: [ - { - name: error.cause?.constructor.name ?? error.name, - value: error.cause?.message ?? error.message, - inline: false - }, - { - name: "Path", - value: `\`${path}\``, - inline: false - }, - { - name: "Input", - value: `\`\`\`json\n${JSON.stringify(input ?? {}, null, 2).slice( - 0, - 1006 - )}\`\`\``, - inline: false - }, - { - name: "Stack Trace", - value: - error.stack - ?.split("\n") - .slice(1, 5) - .map(line => `\`\`\`${line.trim().replaceAll("at ", "")}\`\`\``) - .join("") ?? "", - inline: false - }, - { - name: "Source", - value: source, - inline: false - }, - { - name: "App Version", - value: `\`${process.env.APP_VERSION ?? "N/A"}\``, - inline: false - } - ] - } - ] - }) - } } diff --git a/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts b/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts new file mode 100644 index 00000000..3997db95 --- /dev/null +++ b/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts @@ -0,0 +1,71 @@ +import { sanitizeLinkedRoleSyncErrorCode } from "~/lib/server/discord/linkedRoleSyncError" +import { protectedProcedure } from "../.." + +type SyncHealth = "not_linked" | "needs_scope" | "needs_reconnect" | "pending" | "ok" | "error" + +function deriveSyncHealth(input: { + linked: boolean + roleConnectionsScopeGranted: boolean + lastSyncedAt: string | null + lastError: string | null +}): SyncHealth { + if (!input.linked) { + return "not_linked" + } + if (!input.roleConnectionsScopeGranted) { + return "needs_scope" + } + if (input.lastError === "http_401" || input.lastError === "http_403") { + return "needs_reconnect" + } + if (input.lastError) { + return "error" + } + if (!input.lastSyncedAt) { + return "pending" + } + return "ok" +} + +export const discordLinkedRolesStatus = protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id + + const row = await ctx.prisma.account.findFirst({ + where: { userId, provider: "discord" }, + select: { + displayName: true, + scope: true, + discordRoleMetadataSyncedAt: true, + discordRoleMetadataSyncError: true + } + }) + + if (!row) { + return { + linked: false as const, + discordUsername: null, + roleConnectionsScopeGranted: false, + lastSyncedAt: null, + lastError: null, + syncHealth: "not_linked" as const + } + } + + const roleConnectionsScopeGranted = row.scope?.includes("role_connections.write") ?? false + const lastSyncedAt = row.discordRoleMetadataSyncedAt?.toISOString() ?? null + const lastError = sanitizeLinkedRoleSyncErrorCode(row.discordRoleMetadataSyncError) + + return { + linked: true as const, + discordUsername: row.displayName, + roleConnectionsScopeGranted, + lastSyncedAt, + lastError, + syncHealth: deriveSyncHealth({ + linked: true, + roleConnectionsScopeGranted, + lastSyncedAt, + lastError + }) + } +}) diff --git a/src/lib/server/trpc/procedures/user/pushDiscordLinkedRoles.ts b/src/lib/server/trpc/procedures/user/pushDiscordLinkedRoles.ts new file mode 100644 index 00000000..57b7c10d --- /dev/null +++ b/src/lib/server/trpc/procedures/user/pushDiscordLinkedRoles.ts @@ -0,0 +1,10 @@ +import { pushLinkedRoleMetadataForUser } from "~/lib/server/discord/pushLinkedRoleMetadata" +import { protectedProcedure } from "../.." + +export const pushDiscordLinkedRoles = protectedProcedure.mutation(async ({ ctx }) => { + const result = await pushLinkedRoleMetadataForUser(ctx.session.user.id) + if (result.ok) { + return { ok: true as const } + } + return { ok: false as const, code: result.code, detail: result.detail } +}) diff --git a/src/lib/server/trpc/router.ts b/src/lib/server/trpc/router.ts index 09db458c..872c684a 100644 --- a/src/lib/server/trpc/router.ts +++ b/src/lib/server/trpc/router.ts @@ -15,8 +15,10 @@ import { createPresignedProfilePicURL } from "./procedures/user/account/createPr import { removeProvider } from "./procedures/user/account/removeProvider" import { addByAPIKey } from "./procedures/user/account/speedrun-com/addByAPIKey" import { deleteUser } from "./procedures/user/delete" +import { discordLinkedRolesStatus } from "./procedures/user/discordLinkedRolesStatus" import { getConnections } from "./procedures/user/getConnections" import { getPrimaryAuthenticatedProfile } from "./procedures/user/getPrimaryAuthenticatedProfile" +import { pushDiscordLinkedRoles } from "./procedures/user/pushDiscordLinkedRoles" import { updateProfile } from "./procedures/user/updateProfile" import { updateUser } from "./procedures/user/updateUser" @@ -27,6 +29,8 @@ export const appRouter = createTRPCRouter({ getConnections: getConnections, getPrimaryProfile: getPrimaryAuthenticatedProfile, + discordLinkedRolesStatus, + pushDiscordLinkedRoles, update: updateUser, updateProfile: updateProfile, diff --git a/src/services/discord/webhook.ts b/src/services/discord/webhook.ts deleted file mode 100644 index ecfa0202..00000000 --- a/src/services/discord/webhook.ts +++ /dev/null @@ -1,43 +0,0 @@ -import "server-only" -import { saferFetch } from "~/lib/server/saferFetch" - -export interface DiscordWebhookData { - embeds: [ - { - color?: number - title?: string - description?: string - fields?: { - name: string - value: string - inline: boolean - }[] - } - ] -} - -export enum DiscordColors { - RED = 0xef0c09 -} - -export const sendDiscordWebhook = async (url: string, data: DiscordWebhookData) => { - const webhookResponse = await saferFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - embeds: data.embeds.slice(0, 10).map(embed => ({ - ...embed, - fields: embed.fields?.slice(0, 25).map(field => ({ - ...field, - name: field.name.slice(0, 256), - value: field.value.slice(0, 1024) - })) - })) - }) - }) - if (!webhookResponse.ok) { - throw new Error(`[${webhookResponse.status}] ${await webhookResponse.text()}`) - } -} diff --git a/src/services/raidhub/RaidHubError.ts b/src/services/raidhub/RaidHubError.ts index b3368dae..60bcbbde 100644 --- a/src/services/raidhub/RaidHubError.ts +++ b/src/services/raidhub/RaidHubError.ts @@ -14,3 +14,15 @@ export class RaidHubError extends Error { this.cause = res.error } } + +/** When the API returns an error envelope with a `message` field (e.g. ServiceUnavailable), surface it for BFF callers. */ +export function getRaidHubErrorEnvelopeMessage(error: RaidHubError): string | undefined { + const c = error.cause + if (c && typeof c === "object" && "message" in c) { + const m = (c as { message: unknown }).message + if (typeof m === "string" && m.trim().length > 0) { + return m + } + } + return undefined +} diff --git a/src/services/raidhub/common.ts b/src/services/raidhub/common.ts index e923a07f..e32bd505 100644 --- a/src/services/raidhub/common.ts +++ b/src/services/raidhub/common.ts @@ -7,6 +7,25 @@ import type { import { RaidHubError } from "./RaidHubError" import type { paths } from "./openapi" +/** openapi-typescript uses `readonly` on `requestBody` / `application/json`. */ +type RequestJsonBody = paths[T][M] extends { + readonly requestBody: infer RB +} + ? RB extends { readonly content: infer C } + ? C extends { readonly "application/json": infer B } + ? B + : C extends { "application/json": infer B } + ? B + : never + : RB extends { content: infer C } + ? C extends { readonly "application/json": infer B } + ? B + : C extends { "application/json": infer B } + ? B + : never + : never + : never + export async function getRaidHubApi< T extends RaidHubGetPath, P = "parameters" extends keyof paths[T]["get"] ? paths[T]["get"]["parameters"] : null, @@ -65,13 +84,7 @@ export async function postRaidHubApi< >( path: T, method: M, - body: "requestBody" extends keyof paths[T][M] - ? "content" extends keyof paths[T][M]["requestBody"] - ? "application/json" extends keyof paths[T][M]["requestBody"]["content"] - ? paths[T][M]["requestBody"]["content"]["application/json"] - : never - : never - : never, + body: "requestBody" extends keyof paths[T][M] ? RequestJsonBody : never, pathParams: "path" extends keyof P ? P["path"] : null, queryParams?: "query" extends keyof P ? P["query"] : null, config?: Omit, diff --git a/src/services/raidhub/internalPaths.ts b/src/services/raidhub/internalPaths.ts new file mode 100644 index 00000000..2ee820da --- /dev/null +++ b/src/services/raidhub/internalPaths.ts @@ -0,0 +1,24 @@ +import type { paths } from "./openapi" +import type { RaidHubPostPath } from "./types" + +/** OpenAPI `paths` keys for BFF calls under ``/internal/*``. */ +export type RaidHubInternalPath = Extract + +/** + * Typed route strings (per-key literals so ``postRaidHubApi`` infers request bodies). + * Regenerate ``openapi.d.ts`` after API route changes; wrong strings fail at compile time + * when used with ``getRaidHubApi`` / ``postRaidHubApi``. + */ +export const RAIDHUB_INTERNAL_PATHS = { + queueDiscordLinkedRoleSync: "/internal/queue-discord-linked-role-sync", + subscriptionsDiscordWebhooks: "/internal/subscriptions/discord/webhooks" +} as const + +export type QueueDiscordLinkedRoleSyncRequestBody = + paths["/internal/queue-discord-linked-role-sync"]["post"]["requestBody"]["content"]["application/json"] + +export type DiscordSubscriptionWebhookPutBody = + paths["/internal/subscriptions/discord/webhooks"]["put"]["requestBody"]["content"]["application/json"] + +const _queueSyncPathIsPostable: RaidHubPostPath = RAIDHUB_INTERNAL_PATHS.queueDiscordLinkedRoleSync +void _queueSyncPathIsPostable diff --git a/src/services/raidhub/openapi.d.ts b/src/services/raidhub/openapi.d.ts index ce12a5de..85d5948c 100644 --- a/src/services/raidhub/openapi.d.ts +++ b/src/services/raidhub/openapi.d.ts @@ -107,6 +107,20 @@ export interface paths { }; }; }; + /** @description ServiceUnavailableError */ + 503: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ServiceUnavailableError"; + readonly error: components["schemas"]["ServiceUnavailableError"]; + }; + }; + }; }; }; }; @@ -121,6 +135,7 @@ export interface paths { parameters: { query: { count?: number; + offset?: number | null; query: string; membershipType?: components["schemas"]["DestinyMembershipType"]; global?: boolean; @@ -1099,6 +1114,14 @@ export interface paths { 400: { content: { readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidActivityVersionComboError"; + readonly error: components["schemas"]["InvalidActivityVersionComboError"]; + } | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -1134,14 +1157,6 @@ export interface paths { /** @enum {string} */ readonly code: "PlayerNotOnLeaderboardError"; readonly error: components["schemas"]["PlayerNotOnLeaderboardError"]; - } | { - /** Format: date-time */ - readonly minted: string; - /** @enum {boolean} */ - readonly success: false; - /** @enum {string} */ - readonly code: "InvalidActivityVersionComboError"; - readonly error: components["schemas"]["InvalidActivityVersionComboError"]; } | { /** Format: date-time */ readonly minted: string; @@ -1516,6 +1531,97 @@ export interface paths { }; }; }; + "/clan/{groupId}/basic": { + /** + * /clan/{groupId}/basic + * @description Low-cost clan identity (name, tag, avatar path) for bots and UIs. Does not load member rosters. + */ + get: { + parameters: { + path: { + groupId: string; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["ClanBasicResponse"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description Not found */ + 404: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ClanNotFoundError"; + readonly error: components["schemas"]["ClanNotFoundError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "PathValidationError"; + readonly error: components["schemas"]["PathValidationError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + /** @description BungieServiceOffline */ + 503: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BungieServiceOffline"; + readonly error: components["schemas"]["BungieServiceOffline"]; + }; + }; + }; + }; + }; + }; "/metrics/weapons/rolling-week": { /** * /metrics/weapons/rolling-week @@ -1742,7 +1848,7 @@ export interface paths { "/admin/reporting/standing/{instanceId}": { /** * /admin/reporting/standing/{instanceId} - * @description Find a set of instances based on the query parameters. Some parameters will not work together, such as providing a season outside the range of the min/max season. Requires authentication. + * @description Get the standing information for a specific instance, including flags, blacklist status, and per-player standing data. */ get: { parameters: { @@ -1777,21 +1883,32 @@ export interface paths { }; }; }; + /** @description Forbidden */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; /** @description Not found */ 404: { content: { - readonly "application/json": ({ + readonly "application/json": { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: false; /** @enum {string} */ readonly code: "InstanceNotFoundError"; - readonly error: components["schemas"]["InstanceNotFoundError"] & { - /** Format: int64 */ - readonly instanceId?: string; - }; - }) | { + readonly error: components["schemas"]["InstanceNotFoundError"]; + } | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -1893,18 +2010,34 @@ export interface paths { }; }; }; + /** @description Forbidden */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; /** @description Not found */ 404: { content: { - readonly "application/json": { + readonly "application/json": ({ /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: false; /** @enum {string} */ readonly code: "InstanceNotFoundError"; - readonly error: components["schemas"]["InstanceNotFoundError"]; - } | { + readonly error: components["schemas"]["InstanceNotFoundError"] & { + readonly instanceId?: string; + }; + }) | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -1935,21 +2068,14 @@ export interface paths { "/admin/reporting/player/{membershipId}": { /** * /admin/reporting/player/{membershipId} - * @description Update fields on a player. Currently, only the cheat level can be updated. + * @description Get a player's standing information including recent flags and blacklisted instances. Requires authentication. */ - patch: { + get: { parameters: { path: { membershipId: string; }; }; - readonly requestBody: { - readonly content: { - readonly "application/json": { - readonly cheatLevel?: components["schemas"]["CheatLevel"]; - }; - }; - }; responses: { /** @description Success */ 200: { @@ -1963,8 +2089,8 @@ export interface paths { }; }; }; - /** @description Bad request */ - 400: { + /** @description Unauthorized */ + 401: { content: { readonly "application/json": { /** Format: date-time */ @@ -1972,13 +2098,13 @@ export interface paths { /** @enum {boolean} */ readonly success: false; /** @enum {string} */ - readonly code: "BodyValidationError"; - readonly error: components["schemas"]["BodyValidationError"]; + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; }; }; }; - /** @description Unauthorized */ - 401: { + /** @description Forbidden */ + 403: { content: { readonly "application/json": { /** Format: date-time */ @@ -1986,26 +2112,23 @@ export interface paths { /** @enum {boolean} */ readonly success: false; /** @enum {string} */ - readonly code: "ApiKeyError"; - readonly error: components["schemas"]["ApiKeyError"]; + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; }; }; }; /** @description Not found */ 404: { content: { - readonly "application/json": ({ + readonly "application/json": { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: false; /** @enum {string} */ readonly code: "PlayerNotFoundError"; - readonly error: components["schemas"]["PlayerNotFoundError"] & { - /** Format: int64 */ - readonly membershipId?: string; - }; - }) | { + readonly error: components["schemas"]["PlayerNotFoundError"]; + } | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -2032,18 +2155,20 @@ export interface paths { }; }; }; - }; - "/authorize/admin": { /** - * /authorize/admin - * @description Authorize an admin user. Requires the client secret. + * /admin/reporting/player/{membershipId} + * @description Update fields on a player. Currently, only the cheat level can be updated. */ - post: { + patch: { + parameters: { + path: { + membershipId: string; + }; + }; readonly requestBody: { readonly content: { readonly "application/json": { - readonly bungieMembershipId: string; - readonly adminClientSecret: string; + readonly cheatLevel?: components["schemas"]["CheatLevel"]; }; }; }; @@ -2056,7 +2181,7 @@ export interface paths { readonly minted: string; /** @enum {boolean} */ readonly success: true; - readonly response: components["schemas"]["AuthorizeAdminResponse"]; + readonly response: components["schemas"]["AdminReportingPlayerResponse"] & string; }; }; }; @@ -2088,7 +2213,7 @@ export interface paths { }; }; }; - /** @description InvalidClientSecretError */ + /** @description Forbidden */ 403: { content: { readonly "application/json": { @@ -2097,8 +2222,30 @@ export interface paths { /** @enum {boolean} */ readonly success: false; /** @enum {string} */ - readonly code: "InvalidClientSecretError"; - readonly error: components["schemas"]["InvalidClientSecretError"]; + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Not found */ + 404: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "PlayerNotFoundError"; + readonly error: components["schemas"]["PlayerNotFoundError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "PathValidationError"; + readonly error: components["schemas"]["PathValidationError"]; }; }; }; @@ -2119,18 +2266,17 @@ export interface paths { }; }; }; - "/authorize/user": { + "/authorize/admin": { /** - * /authorize/user - * @description Authenticate a user. Grants permission to access restricted resources. + * /authorize/admin + * @description Authorize an admin user. Requires the client secret. */ post: { readonly requestBody: { readonly content: { readonly "application/json": { readonly bungieMembershipId: string; - readonly destinyMembershipIds: readonly string[]; - readonly clientSecret: string; + readonly adminClientSecret: string; }; }; }; @@ -2143,7 +2289,7 @@ export interface paths { readonly minted: string; /** @enum {boolean} */ readonly success: true; - readonly response: components["schemas"]["AuthorizeUserResponse"]; + readonly response: components["schemas"]["AuthorizeAdminResponse"]; }; }; }; @@ -2206,16 +2352,454 @@ export interface paths { }; }; }; -} - -export type webhooks = Record; - -export interface components { - schemas: { - /** @enum {string} */ - readonly ErrorCode: "ApiKeyError" | "PathValidationError" | "QueryValidationError" | "BodyValidationError" | "PlayerNotFoundError" | "PlayerPrivateProfileError" | "PlayerProtectedResourceError" | "InstanceNotFoundError" | "PGCRNotFoundError" | "PlayerNotOnLeaderboardError" | "PlayerNotInInstance" | "RaidNotFoundError" | "PantheonVersionNotFoundError" | "InvalidActivityVersionComboError" | "ClanNotFoundError" | "AdminQuerySyntaxError" | "InsufficientPermissionsError" | "InvalidClientSecretError" | "InternalServerError" | "BungieServiceOffline"; - readonly RaidHubResponse: OneOf<[{ - /** Format: date-time */ + "/authorize/user": { + /** + * /authorize/user + * @description Authenticate a user. Grants permission to access restricted resources. + */ + post: { + readonly requestBody: { + readonly content: { + readonly "application/json": { + readonly bungieMembershipId: string; + readonly destinyMembershipIds: readonly string[]; + readonly clientSecret: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["AuthorizeUserResponse"]; + }; + }; + }; + /** @description Bad request */ + 400: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BodyValidationError"; + readonly error: components["schemas"]["BodyValidationError"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InvalidClientSecretError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidClientSecretError"; + readonly error: components["schemas"]["InvalidClientSecretError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + }; + "/internal/queue-discord-linked-role-sync": { + /** + * /internal/queue-discord-linked-role-sync + * @description Queue a Discord linked-role metadata sync. Body: Destiny membership ids only. Send `x-raidhub-client-secret: ` (not in JSON). Refresh Discord OAuth in the BFF before calling. + */ + post: { + readonly requestBody: { + readonly content: { + readonly "application/json": { + readonly destinyMembershipIds: readonly string[]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalQueueDiscordLinkedRoleSyncResponse"]; + }; + }; + }; + /** @description Bad request */ + 400: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BodyValidationError"; + readonly error: components["schemas"]["BodyValidationError"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InvalidClientSecretError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidClientSecretError"; + readonly error: components["schemas"]["InvalidClientSecretError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + /** @description ServiceUnavailableError */ + 503: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ServiceUnavailableError"; + readonly error: components["schemas"]["ServiceUnavailableError"]; + }; + }; + }; + }; + }; + }; + "/internal/subscriptions/discord/webhooks": { + /** + * /internal/subscriptions/discord/webhooks + * @description Get RaidHub subscription webhook status for the current channel (no secrets). + */ + get: { + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalSubscriptionsDiscordWebhooksResponse"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidDiscordAuthError"; + readonly error: components["schemas"]["InvalidDiscordAuthError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InsufficientPermissionsError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + /** + * /internal/subscriptions/discord/webhooks + * @description Create or update the RaidHub subscription webhook for this channel (idempotent upsert). + */ + put: { + readonly requestBody: { + readonly content: { + readonly "application/json": components["schemas"]["DiscordWebhookBody"]; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalSubscriptionsDiscordWebhooksResponse"] & { + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + /** Format: uri */ + readonly webhookUrl?: string; + readonly created: boolean; + readonly activated: boolean; + readonly updated: boolean; + readonly rules: { + readonly players: { + readonly inserted: number; + readonly updated: number; + }; + readonly clans: { + readonly inserted: number; + readonly updated: number; + }; + }; + }; + }; + }; + }; + /** @description Bad request */ + 400: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BodyValidationError"; + readonly error: components["schemas"]["BodyValidationError"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidDiscordAuthError"; + readonly error: components["schemas"]["InvalidDiscordAuthError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InsufficientPermissionsError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + /** + * /internal/subscriptions/discord/webhooks + * @description Delete a Discord subscription webhook registration for the current channel. + */ + delete: { + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalSubscriptionsDiscordWebhooksResponse"] & { + readonly deleted: boolean; + }; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidDiscordAuthError"; + readonly error: components["schemas"]["InvalidDiscordAuthError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InsufficientPermissionsError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + }; +} + +export type webhooks = Record; + +export interface components { + schemas: { + /** @enum {string} */ + readonly ErrorCode: "ApiKeyError" | "PathValidationError" | "QueryValidationError" | "BodyValidationError" | "PlayerNotFoundError" | "PlayerPrivateProfileError" | "PlayerProtectedResourceError" | "InstanceNotFoundError" | "PGCRNotFoundError" | "PlayerNotOnLeaderboardError" | "PlayerNotInInstance" | "RaidNotFoundError" | "PantheonVersionNotFoundError" | "InvalidActivityVersionComboError" | "ClanNotFoundError" | "AdminQuerySyntaxError" | "InsufficientPermissionsError" | "InvalidClientSecretError" | "InvalidDiscordAuthError" | "InternalServerError" | "ServiceUnavailableError" | "BungieServiceOffline"; + readonly RaidHubResponse: OneOf<[{ + /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: true; @@ -2281,7 +2865,7 @@ export interface components { readonly versionId: number; /** @description If the instance was completed before the day one end date */ readonly isDayOne: boolean; - /** @description If the instance was completed before the contest end date */ + /** @description If this clear was contest mode: when the activity exposes version_id 32 (contest) on activity_version, true only for that version while still before contest_end; otherwise true when completed before contest_end (legacy raids). */ readonly isContest: boolean; /** @description If the instance was completed before the week one end date */ readonly isWeekOne: boolean; @@ -2337,7 +2921,7 @@ export interface components { /** Format: int64 */ readonly membershipId: string; /** @description The platform on which the player created their account. */ - readonly membershipType: components["schemas"]["DestinyMembershipType"] | null; + readonly membershipType: components["schemas"]["DestinyMembershipType"]; readonly iconPath: string | null; /** @description The platform-specific display name of the player. No longer shown in-game. */ readonly displayName: string | null; @@ -2445,6 +3029,8 @@ export interface components { readonly instanceId: string; /** Format: int64 */ readonly membershipId: string; + /** Format: date-time */ + readonly instanceDate: string; }; readonly InstancePlayerStanding: { readonly playerInfo: components["schemas"]["PlayerInfo"]; @@ -2464,6 +3050,24 @@ export interface components { })[]; readonly otherRecentFlags: readonly components["schemas"]["InstancePlayerFlag"][]; }; + readonly PlayerBlacklistedInstance: { + /** Format: int64 */ + readonly instanceId: string; + /** Format: date-time */ + readonly instanceDate: string; + readonly reason: string; + readonly individualReason: string | null; + /** Format: date-time */ + readonly createdAt: string; + }; + readonly ClanBasic: { + /** Format: int64 */ + readonly groupId: string; + readonly name: string; + readonly callSign: string; + readonly motto: string; + readonly avatarPath: string | null; + }; readonly ClanBannerData: { readonly decalId: number; readonly decalColorId: number; @@ -2527,13 +3131,6 @@ export interface components { readonly totalTimePlayedSeconds: number; readonly contestScore: number; }; - readonly ClanStats: { - readonly aggregateStats: components["schemas"]["ClanAggregateStats"]; - readonly members: readonly ({ - readonly playerInfo: components["schemas"]["PlayerInfo"] | null; - readonly stats: components["schemas"]["ClanMemberStats"]; - })[]; - }; readonly InstanceMetadata: { readonly activityName: string; readonly versionName: string; @@ -2575,11 +3172,78 @@ export interface components { readonly playerInfo: components["schemas"]["PlayerInfo"]; readonly characters: readonly components["schemas"]["InstanceCharacter"][]; }; - readonly InstanceExtended: components["schemas"]["Instance"] & ({ - readonly leaderboardRank: number | null; - readonly metadata: components["schemas"]["InstanceMetadata"]; - readonly players: readonly components["schemas"]["InstancePlayerExtended"][]; - }); + /** @default {} */ + readonly DiscordWebhookBody: { + readonly name?: string; + readonly targets?: { + readonly players?: readonly { + readonly membershipId: string; + readonly requireFresh?: boolean; + readonly requireCompleted?: boolean; + readonly raids?: readonly number[]; + }[]; + readonly clans?: readonly { + readonly groupId: string; + readonly requireFresh?: boolean; + readonly requireCompleted?: boolean; + readonly raids?: readonly number[]; + }[]; + }; + }; + readonly DiscordWebhookPutResponse: { + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + /** Format: uri */ + readonly webhookUrl?: string; + readonly created: boolean; + readonly activated: boolean; + readonly updated: boolean; + readonly rules: { + readonly players: { + readonly inserted: number; + readonly updated: number; + }; + readonly clans: { + readonly inserted: number; + readonly updated: number; + }; + }; + }; + readonly DiscordWebhookDeleteResponse: { + readonly deleted: boolean; + }; + readonly DiscordWebhookStatusResponse: OneOf<[{ + /** @enum {boolean} */ + readonly registered: false; + }, { + /** @enum {boolean} */ + readonly registered: true; + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + readonly destinationActive: boolean; + readonly consecutiveDeliveryFailures: number; + readonly lastDeliverySuccessAt: string | null; + readonly lastDeliveryFailureAt: string | null; + readonly lastDeliveryError: string | null; + readonly players: readonly { + readonly membershipId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + readonly clans: readonly { + readonly groupId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + }]>; + readonly InvalidDiscordAuthError: { + /** @enum {string} */ + readonly message: "Invalid Discord context token"; + }; readonly TeamLeaderboardEntry: { readonly position: number; readonly rank: number; @@ -2594,23 +3258,6 @@ export interface components { readonly value: number; readonly playerInfo: components["schemas"]["PlayerInfo"]; }; - readonly LeaderboardData: OneOf<[{ - /** @enum {string} */ - readonly type: "team"; - /** @enum {string} */ - readonly format: "duration" | "numerical"; - readonly page: number; - readonly count: number; - readonly entries: readonly components["schemas"]["TeamLeaderboardEntry"][]; - }, { - /** @enum {string} */ - readonly type: "individual"; - /** @enum {string} */ - readonly format: "duration" | "numerical"; - readonly page: number; - readonly count: number; - readonly entries: readonly components["schemas"]["IndividualLeaderboardEntry"][]; - }]>; /** @enum {string} */ readonly IndividualGlobalLeaderboardCategory: "clears" | "full-clears" | "sherpas" | "speedrun" | "world-first-rankings" | "in-raid-time"; /** @description Pagination parameters for leaderboard data */ @@ -2675,7 +3322,7 @@ export interface components { * @example medium * @enum {string} */ - readonly ImageSize: "tiny" | "small" | "medium" | "large" | "xlarge"; + readonly ImageSize: "tiny" | "small" | "medium" | "large" | "xlarge" | "full"; /** * @description A URL to a piece of content hosted on the RaidHub CDN. * @example { @@ -2714,92 +3361,15 @@ export interface components { readonly PopulationByRaidMetric: { [key: string]: number; }; - /** @description A raw PGCR with a few redundant fields removed */ - readonly RaidHubPostGameCarnageReport: { - /** Format: date-time */ - readonly period: string; - readonly startingPhaseIndex?: number; - readonly activityWasStartedFromBeginning?: boolean; - readonly activityDetails: { - /** Format: uint32 */ - readonly directorActivityHash: number; - /** Format: int64 */ - readonly instanceId: string; - /** @enum {integer} */ - readonly mode: 0 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91; - readonly modes: readonly (0 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91)[]; - readonly membershipType: components["schemas"]["DestinyMembershipType"]; - }; - readonly activityDifficultyTier?: number; - readonly selectedSkullHashes?: readonly number[]; - readonly entries: readonly ({ - readonly player: { - readonly destinyUserInfo: { - readonly iconPath?: string | null; - readonly crossSaveOverride: components["schemas"]["DestinyMembershipType"]; - readonly applicableMembershipTypes?: (readonly components["schemas"]["DestinyMembershipType"][]) | null; - readonly membershipType?: components["schemas"]["DestinyMembershipType"] | null; - readonly membershipId: string; - readonly displayName?: string | null; - readonly bungieGlobalDisplayName?: string | null; - readonly bungieGlobalDisplayNameCode?: number | null; - }; - readonly characterClass?: string | null; - /** Format: uint32 */ - readonly classHash: number; - /** Format: uint32 */ - readonly raceHash: number; - /** Format: uint32 */ - readonly genderHash: number; - readonly characterLevel: number; - readonly lightLevel: number; - /** Format: uint32 */ - readonly emblemHash: number; - }; - readonly characterId: string; - readonly values: { - [key: string]: { - readonly basic: { - readonly value: number; - readonly displayValue: string; - }; - }; - }; - readonly extended?: { - readonly weapons?: (readonly { - readonly referenceId: number; - readonly values: { - [key: string]: { - readonly basic: { - readonly value: number; - readonly displayValue: string; - }; - }; - }; - }[]) | null; - readonly values: { - [key: string]: { - readonly basic: { - readonly value: number; - readonly displayValue: string; - }; - }; - }; - }; - })[]; - }; readonly InstanceForPlayer: components["schemas"]["Instance"] & { readonly player: components["schemas"]["InstancePlayer"]; }; - readonly InstanceWithPlayers: components["schemas"]["Instance"] & { - readonly players: readonly components["schemas"]["PlayerInfo"][]; - }; readonly PlayerProfileActivityStats: { readonly activityId: number; readonly freshClears: number; readonly clears: number; readonly sherpas: number; - readonly fastestInstance: components["schemas"]["Instance"] | null; + readonly fastestInstance: components["schemas"]["Instance"]; }; readonly GlobalStat: { readonly value: number; @@ -2825,18 +3395,6 @@ export interface components { readonly isWeekOne: boolean; readonly isChallengeMode: boolean; }; - readonly PlayerProfile: { - readonly playerInfo: components["schemas"]["PlayerInfo"]; - readonly stats: { - readonly global: components["schemas"]["PlayerProfileGlobalStats"]; - readonly activity: { - [key: string]: components["schemas"]["PlayerProfileActivityStats"]; - }; - }; - readonly worldFirstEntries: { - [key: string]: components["schemas"]["WorldFirstEntry"] | null; - }; - }; readonly Teammate: { readonly estimatedTimePlayedSeconds: number; readonly clears: number; @@ -2864,7 +3422,7 @@ export interface components { readonly incomingRate: number; readonly resolveRate: number; readonly backlog: number; - readonly latestResolvedInstance: components["schemas"]["LatestResolvedInstance"] | null; + readonly latestResolvedInstance: components["schemas"]["LatestResolvedInstance"]; /** Format: date-time */ readonly estimatedBacklogEmptied: string | null; }; @@ -2920,23 +3478,31 @@ export interface components { readonly AtlasPGCR: components["schemas"]["AtlasStatus"]; readonly FloodgatesPGCR: components["schemas"]["FloodgatesStatus"]; }; + readonly ServiceUnavailableError: { + readonly serviceName: string; + readonly message: string; + }; readonly PlayerSearchResponse: { readonly params: { readonly count: number; + readonly offset: number; readonly query: string; }; readonly results: readonly components["schemas"]["PlayerInfo"][]; }; readonly PlayerHistoryResponse: { + /** Format: int64 */ readonly membershipId: string; /** Format: date-time */ readonly nextCursor: string | null; readonly activities: readonly components["schemas"]["InstanceForPlayer"][]; }; readonly PlayerNotFoundError: { + /** Format: int64 */ readonly membershipId: string; }; readonly PlayerPrivateProfileError: { + /** Format: int64 */ readonly membershipId: string; }; /** @@ -2956,7 +3522,7 @@ export interface components { /** Format: int64 */ readonly membershipId: string; /** @description The platform on which the player created their account. */ - readonly membershipType: components["schemas"]["DestinyMembershipType"] | null; + readonly membershipType: components["schemas"]["DestinyMembershipType"]; readonly iconPath: string | null; /** @description The platform-specific display name of the player. No longer shown in-game. */ readonly displayName: string | null; @@ -2977,13 +3543,16 @@ export interface components { }; }; readonly worldFirstEntries: { - [key: string]: components["schemas"]["WorldFirstEntry"] | null; + [key: string]: components["schemas"]["WorldFirstEntry"]; }; }; readonly PlayerTeammatesResponse: readonly components["schemas"]["Teammate"][]; - readonly PlayerInstancesResponse: readonly components["schemas"]["InstanceWithPlayers"][]; + readonly PlayerInstancesResponse: readonly (components["schemas"]["Instance"] & { + readonly players: readonly components["schemas"]["PlayerInfo"][]; + })[]; readonly PlayerProtectedResourceError: { readonly message: string; + /** Format: int64 */ readonly membershipId: string; }; readonly InstanceResponse: components["schemas"]["Instance"] & ({ @@ -2992,6 +3561,7 @@ export interface components { readonly players: readonly components["schemas"]["InstancePlayerExtended"][]; }); readonly InstanceNotFoundError: { + /** Format: int64 */ readonly instanceId: string; }; readonly LeaderboardIndividualGlobalResponse: OneOf<[{ @@ -3012,6 +3582,7 @@ export interface components { readonly entries: readonly components["schemas"]["IndividualLeaderboardEntry"][]; }]>; readonly PlayerNotOnLeaderboardError: { + /** Format: int64 */ readonly membershipId: string; }; readonly LeaderboardIndividualRaidResponse: OneOf<[{ @@ -3117,7 +3688,8 @@ export interface components { readonly iconPath?: string | null; readonly crossSaveOverride: components["schemas"]["DestinyMembershipType"]; readonly applicableMembershipTypes?: (readonly components["schemas"]["DestinyMembershipType"][]) | null; - readonly membershipType?: components["schemas"]["DestinyMembershipType"] | null; + readonly membershipType?: components["schemas"]["DestinyMembershipType"]; + /** Format: int64 */ readonly membershipId: string; readonly displayName?: string | null; readonly bungieGlobalDisplayName?: string | null; @@ -3135,6 +3707,7 @@ export interface components { /** Format: uint32 */ readonly emblemHash: number; }; + /** Format: int64 */ readonly characterId: string; readonly values: { [key: string]: { @@ -3168,22 +3741,32 @@ export interface components { })[]; }; readonly PGCRNotFoundError: { + /** Format: int64 */ readonly instanceId: string; }; readonly ClanResponse: { readonly aggregateStats: components["schemas"]["ClanAggregateStats"]; - readonly members: readonly ({ - readonly playerInfo: components["schemas"]["PlayerInfo"] | null; + readonly members: readonly { + readonly playerInfo: components["schemas"]["PlayerInfo"]; readonly stats: components["schemas"]["ClanMemberStats"]; - })[]; + }[]; }; readonly ClanNotFoundError: { + /** Format: int64 */ readonly groupId: string; }; readonly BungieServiceOffline: { readonly message: string; readonly route: string; }; + readonly ClanBasicResponse: { + /** Format: int64 */ + readonly groupId: string; + readonly name: string; + readonly callSign: string; + readonly motto: string; + readonly avatarPath: string | null; + }; readonly MetricsWeaponsRollingWeekResponse: { readonly energy: readonly components["schemas"]["WeaponMetric"][]; readonly kinetic: readonly components["schemas"]["WeaponMetric"][]; @@ -3219,7 +3802,7 @@ export interface components { }; readonly AdminReportingStandingResponse: { readonly instanceDetails: components["schemas"]["InstanceBasic"]; - readonly blacklist: components["schemas"]["InstanceBlacklist"] | null; + readonly blacklist: components["schemas"]["InstanceBlacklist"]; readonly flags: readonly components["schemas"]["InstanceFlag"][]; readonly players: readonly components["schemas"]["InstancePlayerStanding"][]; }; @@ -3227,10 +3810,15 @@ export interface components { readonly blacklisted: boolean; }; readonly PlayerNotInInstance: { + /** Format: int64 */ readonly instanceId: string; readonly players: readonly string[]; }; - readonly AdminReportingPlayerResponse: string; + readonly AdminReportingPlayerResponse: { + readonly playerInfo: components["schemas"]["PlayerInfo"]; + readonly recentFlags: readonly components["schemas"]["InstancePlayerFlag"][]; + readonly blacklistedInstances: readonly components["schemas"]["PlayerBlacklistedInstance"][]; + }; readonly AuthorizeAdminResponse: { readonly value: string; /** Format: date-time */ @@ -3242,6 +3830,38 @@ export interface components { /** Format: date-time */ readonly expires: string; }; + readonly InternalQueueDiscordLinkedRoleSyncResponse: { + /** @enum {boolean} */ + readonly queued: true; + readonly destinyMembershipIds: readonly string[]; + }; + readonly InternalSubscriptionsDiscordWebhooksResponse: OneOf<[{ + /** @enum {boolean} */ + readonly registered: false; + }, { + /** @enum {boolean} */ + readonly registered: true; + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + readonly destinationActive: boolean; + readonly consecutiveDeliveryFailures: number; + readonly lastDeliverySuccessAt: string | null; + readonly lastDeliveryFailureAt: string | null; + readonly lastDeliveryError: string | null; + readonly players: readonly { + readonly membershipId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + readonly clans: readonly { + readonly groupId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + }]>; }; responses: never; parameters: { diff --git a/src/services/raidhub/types.ts b/src/services/raidhub/types.ts index 80586e0f..1ba960c1 100644 --- a/src/services/raidhub/types.ts +++ b/src/services/raidhub/types.ts @@ -45,8 +45,9 @@ export type RaidHubFeatDefinition = Component<"FeatDefinition"> export type RaidHubPlayerInfo = Component<"PlayerInfo"> export type RaidHubInstance = Component<"Instance"> -export type RaidHubInstanceExtended = Component<"InstanceExtended"> -export type RaidHubInstanceWithPlayers = Component<"InstanceWithPlayers"> +export type RaidHubInstanceExtended = Component<"InstanceResponse"> +/** One row from GET /player/{membershipId}/instances — `Instance` plus roster `players`. */ +export type RaidHubInstanceWithPlayers = components["schemas"]["PlayerInstancesResponse"][number] export type RaidHubInstancePlayerExtended = Component<"InstancePlayerExtended"> export type RaidHubInstanceCharacter = Component<"InstanceCharacter"> export type RaidHubInstanceForPlayer = Component<"InstanceForPlayer"> @@ -55,7 +56,13 @@ export type RaidHubClanMemberStats = Component<"ClanMemberStats"> export type RaidHubWeaponMetric = Component<"WeaponMetric"> -export type RaidHubLeaderboardData = Component<"LeaderboardData"> +/** Union of leaderboard GET responses that use team vs individual entries (excludes clan-only shape). */ +export type RaidHubLeaderboardData = + | components["schemas"]["LeaderboardIndividualGlobalResponse"] + | components["schemas"]["LeaderboardIndividualRaidResponse"] + | components["schemas"]["LeaderboardIndividualPantheonResponse"] + | components["schemas"]["LeaderboardTeamFirstResponse"] + | components["schemas"]["LeaderboardTeamContestResponse"] export type RaidHubIndividualLeaderboardEntry = Component<"IndividualLeaderboardEntry"> export type RaidHubLeaderboardURL = RaidHubGetPath & @@ -128,9 +135,10 @@ interface GetSchema { } interface PostSchema { - requestBody?: { - content: { - "application/json": unknown + /** openapi-ts marks `requestBody` readonly; must match for `RaidHubPostPath` / `KeysWhichValuesExtend`. */ + readonly requestBody?: { + readonly content: { + readonly "application/json": unknown } } parameters?: { @@ -139,7 +147,7 @@ interface PostSchema { } responses: { 200: { - content: { + readonly content: { readonly "application/json": unknown } } diff --git a/src/types/api.ts b/src/types/api.ts index a03ae98c..277d8a2b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,6 +3,10 @@ import { type AppRouter } from "~/lib/server/trpc" export type RouterOutput = inferRouterOutputs +/** tRPC `user.discordLinkedRolesStatus` — use from client UI instead of importing server procedure types. */ +export type DiscordLinkedRoleSyncHealth = + RouterOutput["user"]["discordLinkedRolesStatus"]["syncHealth"] + export type AppProfile = RouterOutput["profile"]["getUnique"] export type AppUserUpdate = RouterOutput["user"]["update"] export type AppRole = "ADMIN" | "USER"