feat: add share schema and Discord account reconciliation

This commit is contained in:
2026-08-16 00:48:24 +07:00 Unverified
parent 59a9cc1c19
commit b8e6389ac3
11 changed files with 2517 additions and 13 deletions
+59 -1
View File
@@ -5,10 +5,33 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { accounts, sessions, users, verificationTokens } from "@/db/schema";
import type { SessionWithDiscord } from "@/lib/auth/auth";
import { ensureDiscordUser } from "@/lib/auth/discord-user";
import { addDiscordGuildMember } from "@/lib/discord/discord";
const discordIdCache = new Map<string, string>();
type DiscordOAuthProfile = {
id: string;
username: string;
global_name?: string | null;
discriminator?: string;
avatar?: string | null;
email?: string | null;
};
function discordAvatarUrl(profile: DiscordOAuthProfile) {
if (profile.avatar) {
const format = profile.avatar.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}?size=128`;
}
const discriminator = Number(profile.discriminator ?? 0);
const index = discriminator > 0
? discriminator % 5
: Number((BigInt(profile.id) >> BigInt(22)) % BigInt(6));
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
}
export const authOptions: NextAuthOptions = {
adapter: DrizzleAdapter(db, {
usersTable: users,
@@ -25,11 +48,46 @@ export const authOptions: NextAuthOptions = {
scope: "identify email guilds.join",
},
},
profile(profile: DiscordOAuthProfile) {
return {
id: profile.id,
name: profile.global_name?.trim() || profile.username,
email: profile.email,
image: discordAvatarUrl(profile),
};
},
}),
],
callbacks: {
async signIn({ account }) {
async signIn({ account, profile, user }) {
if (account?.provider === "discord" && account.providerAccountId && account.access_token) {
const discordProfile = profile as DiscordOAuthProfile | undefined;
const displayName =
discordProfile?.global_name?.trim() ||
discordProfile?.username ||
user.name ||
"Discord user";
const avatarUrl = discordProfile
? discordAvatarUrl(discordProfile)
: user.image ?? null;
const reconciled = await ensureDiscordUser({
discordId: account.providerAccountId,
displayName,
avatarUrl,
email: discordProfile?.email ?? user.email ?? null,
account: {
type: "oauth",
refresh_token: account.refresh_token,
access_token: account.access_token,
expires_at: account.expires_at,
token_type: account.token_type,
scope: account.scope,
id_token: account.id_token,
session_state: account.session_state,
},
});
discordIdCache.set(reconciled.id, account.providerAccountId);
await addDiscordGuildMember(account.providerAccountId, account.access_token);
}
+132
View File
@@ -0,0 +1,132 @@
import "server-only";
import type { AdapterAccount } from "next-auth/adapters";
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { accounts, users } from "@/db/schema";
export type DiscordIdentity = {
discordId: string;
displayName: string;
avatarUrl: string | null;
email?: string | null;
account?: Partial<AdapterAccount>;
};
function accountUpdate(account: Partial<AdapterAccount> | undefined) {
if (!account) return {};
return {
...(account.type !== undefined ? { type: account.type } : {}),
...(account.refresh_token !== undefined
? { refresh_token: account.refresh_token }
: {}),
...(account.access_token !== undefined
? { access_token: account.access_token }
: {}),
...(account.expires_at !== undefined
? { expires_at: account.expires_at }
: {}),
...(account.token_type !== undefined
? { token_type: account.token_type }
: {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
...(account.session_state !== undefined
? { session_state: account.session_state as string | null }
: {}),
};
}
export async function ensureDiscordUser(
identity: DiscordIdentity
): Promise<typeof users.$inferSelect> {
const { discordId, displayName, avatarUrl, email, account } = identity;
return db.transaction(async (tx) => {
const [linked] = await tx
.select({ userId: accounts.userId })
.from(accounts)
.where(
and(
eq(accounts.provider, "discord"),
eq(accounts.providerAccountId, discordId)
)
)
.limit(1);
let userId = linked?.userId;
if (!userId) {
const [user] = await tx
.insert(users)
.values({
discordId,
name: displayName,
image: avatarUrl,
...(email !== undefined ? { email } : {}),
})
.onConflictDoUpdate({
target: users.discordId,
set: {
name: displayName,
image: avatarUrl,
...(email !== undefined ? { email } : {}),
},
})
.returning({ id: users.id });
userId = user.id;
await tx
.insert(accounts)
.values({
userId,
provider: "discord",
providerAccountId: discordId,
type: account?.type ?? "oauth",
...accountUpdate(account),
})
.onConflictDoNothing({
target: [accounts.provider, accounts.providerAccountId],
});
const [winner] = await tx
.select({ userId: accounts.userId })
.from(accounts)
.where(
and(
eq(accounts.provider, "discord"),
eq(accounts.providerAccountId, discordId)
)
)
.limit(1);
if (!winner) throw new Error("Failed to link Discord account");
userId = winner.userId;
}
const [user] = await tx
.update(users)
.set({
discordId,
name: displayName,
image: avatarUrl,
...(email !== undefined ? { email } : {}),
})
.where(eq(users.id, userId))
.returning();
const nextAccount = accountUpdate(account);
if (Object.keys(nextAccount).length > 0) {
await tx
.update(accounts)
.set(nextAccount)
.where(
and(
eq(accounts.provider, "discord"),
eq(accounts.providerAccountId, discordId)
)
);
}
if (!user) throw new Error("Failed to reconcile Discord user");
return user;
});
}