Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ export function DiscordConciergeLink() {
const createChannel =
trpc.organizations.createDiscordConciergeChannel.useMutation({
onSuccess: (data) => {
// Open Discord invite in new tab (noopener for security)
window.open(data.inviteUrl, '_blank', 'noopener,noreferrer')
setIsLoading(false)
window.location.href = data.oauthUrl
},
onError: (error) => {
console.error('Failed to create Discord channel:', error)
Expand Down Expand Up @@ -50,9 +48,7 @@ export function DiscordConciergeLink() {
onClick={handleClick}
disabled={isLoading}
>
{isLoading
? 'Creating channel...'
: 'Join Concierge Channel'}
{isLoading ? 'Connecting...' : 'Join Concierge Channel'}
</Button>
{hasError ? (
<p className="text-xs text-destructive text-center">
Expand Down
135 changes: 135 additions & 0 deletions platform/flowglad-next/src/app/oauth/callback/discord/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Result } from 'better-result'
import { redirect } from 'next/navigation'
import { adminTransaction } from '@/db/adminTransaction'
import { authenticatedTransaction } from '@/db/authenticatedTransaction'
import {
selectOrganizationById,
updateOrganization,
} from '@/db/tableMethods/organizationMethods'
import {
addUserToGuild,
exchangeDiscordOAuthCode,
getDiscordChannelUrl,
getDiscordConfig,
getDiscordUserFromToken,
getOrCreateConciergeChannel,
grantChannelAccess,
} from '@/utils/discord'
import {
decodeDiscordOAuthState,
validateAndConsumeDiscordOAuthCsrfToken,
} from '@/utils/discordOAuthState'
import { logger } from '@/utils/logger'
export default async function DiscordOAuthCallbackPage({
searchParams,
}: {
searchParams: Promise<{ code?: string; state?: string }>
}) {
const { code, state } = await searchParams

if (!code || !state) {
redirect('/onboarding')
}

let redirectUrl: string

try {
const csrfToken = decodeDiscordOAuthState(state)

const userId = (
await authenticatedTransaction(async ({ userId }) =>
Result.ok(userId)
)
).unwrap()

const validation = await validateAndConsumeDiscordOAuthCsrfToken({
csrfToken,
expectedUserId: userId,
})

if (!validation) {
throw new Error('CSRF validation failed')
}

const config = getDiscordConfig()
const tokenResult = await exchangeDiscordOAuthCode({
code,
config,
})

const discordUser = await getDiscordUserFromToken(
tokenResult.access_token
)

await addUserToGuild({
guildId: config.guildId,
discordUserId: discordUser.id,
accessToken: tokenResult.access_token,
config,
})

// Fetch org to get existing channel ID (if any) and org name
const organization = (
await adminTransaction(async ({ transaction }) => {
return selectOrganizationById(
validation.organizationId,
transaction
)
})
).unwrap()

// Create or reuse existing concierge channel
const { channelId } = await getOrCreateConciergeChannel(
organization.name,
organization.discordConciergeChannelId,
discordUser.id
)

// Persist channel ID if it changed
if (channelId !== organization.discordConciergeChannelId) {
;(
await adminTransaction(async ({ transaction }) => {
await updateOrganization(
{
id: validation.organizationId,
discordConciergeChannelId: channelId,
},
transaction
)
return Result.ok(undefined)
})
).unwrap()
}

// Re-read org to get actual stored channel (handles concurrent race)
const updatedOrg = (
await adminTransaction(async ({ transaction }) => {
return selectOrganizationById(
validation.organizationId,
transaction
)
})
).unwrap()

const actualChannelId =
updatedOrg.discordConciergeChannelId ?? channelId

await grantChannelAccess({
channelId: actualChannelId,
discordUserId: discordUser.id,
config,
})

redirectUrl = getDiscordChannelUrl(
config.guildId,
actualChannelId
)
} catch (error) {
logger.error('Discord OAuth callback failed', {
error: error instanceof Error ? error.message : String(error),
})
redirectUrl = '/onboarding?error=discord_connection_failed'
}

redirect(redirectUrl)
}
Original file line number Diff line number Diff line change
@@ -1,110 +1,45 @@
import { organizations } from '@db-core/schema/organizations'
import { TRPCError } from '@trpc/server'
import { Result } from 'better-result'
import { and, eq, isNull } from 'drizzle-orm'
import { z } from 'zod'
import { adminTransaction } from '@/db/adminTransaction'
import { selectOrganizationById } from '@/db/tableMethods/organizationMethods'
import { protectedProcedure } from '@/server/trpc'
import { getOrCreateConciergeChannel } from '@/utils/discord'
import {
buildDiscordOAuthUrl,
getDiscordConfig,
} from '@/utils/discord'
import {
createDiscordOAuthCsrfToken,
encodeDiscordOAuthState,
} from '@/utils/discordOAuthState'

export const createDiscordConciergeChannelSchema = z.object({})

export const createDiscordConciergeChannel = protectedProcedure
.input(createDiscordConciergeChannelSchema)
.output(z.object({ inviteUrl: z.string() }))
.output(z.object({ oauthUrl: z.string() }))
.mutation(async ({ ctx }) => {
const { organizationId } = ctx
const userId = ctx.user?.id

if (!organizationId) {
if (!organizationId || !userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Organization context required',
})
}

try {
// Fetch fresh organization data to get latest discordConciergeChannelId
const organization = (
await adminTransaction(async ({ transaction }) => {
return selectOrganizationById(organizationId, transaction)
})
).unwrap()

// Create or get concierge channel (pass existing ID for fast lookup)
const { channelId, inviteUrl } =
await getOrCreateConciergeChannel(
organization.name,
organization.discordConciergeChannelId
)

// Persist channel ID using conditional update to prevent race conditions.
// If a concurrent request already set the channel ID, the WHERE clause
// won't match, and we fall back to the winner's channel.
if (channelId !== organization.discordConciergeChannelId) {
const persistResult = await adminTransaction(
async ({ transaction }) => {
const condition =
organization.discordConciergeChannelId === null
? and(
eq(organizations.id, organizationId),
isNull(organizations.discordConciergeChannelId)
)
: and(
eq(organizations.id, organizationId),
eq(
organizations.discordConciergeChannelId,
organization.discordConciergeChannelId
)
)

const [updated] = await transaction
.update(organizations)
.set({
discordConciergeChannelId: channelId,
updatedAt: Date.now(),
})
.where(condition)
.returning()

if (!updated) {
// Lost the race — fetch the winner's channel ID
const winnerResult = await selectOrganizationById(
organizationId,
transaction
)
const winner = winnerResult.unwrap()
return Result.ok({
raceResolved: true,
winnerChannelId: winner.discordConciergeChannelId,
})
}

return Result.ok({
raceResolved: false,
winnerChannelId: null,
})
}
)

const { raceResolved, winnerChannelId } =
persistResult.unwrap()

if (raceResolved && winnerChannelId) {
// Return invite for the channel that was persisted first
const winner = await getOrCreateConciergeChannel(
organization.name,
winnerChannelId
)
return { inviteUrl: winner.inviteUrl }
}
}
const config = getDiscordConfig()
const csrfToken = await createDiscordOAuthCsrfToken({
userId,
organizationId,
})
const state = encodeDiscordOAuthState(csrfToken)
const oauthUrl = buildDiscordOAuthUrl({ state, config })

return { inviteUrl }
return { oauthUrl }
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create Discord channel',
message: 'Failed to initiate Discord OAuth',
cause: error,
})
}
Expand Down
Loading