Skip to content
Merged
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
60 changes: 60 additions & 0 deletions src/lib/actions/auth-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Authentication Guard for Server Actions
*
* Provides a reusable `withAuth` wrapper that handles Supabase client creation
* and user authentication, eliminating the repeated 4-line auth boilerplate
* across all Server Actions.
*
* @example
* ```ts
* // Before: repeated in every action
* const supabase = await createClient()
* const { data: { user }, error } = await supabase.auth.getUser()
* if (error || !user) throw new Error('Authentication required')
*
* // After: single-line wrapper
* export const deleteBoard = (id: string) =>
* withAuth(async (supabase, user) => { ... })
* ```
*/

'use server'

import type { SupabaseClient, User } from '@supabase/supabase-js'

import { createClient } from '@/lib/supabase/server'
import type { Database } from '@/lib/supabase/types'

/**
* Wraps a Server Action with authentication, providing an authenticated
* Supabase client and the current user.
*
* @param action - Async function receiving authenticated supabase client and user
* @returns The action's return value, or throws if not authenticated
* @throws {Error} 'Authentication required' when user is not logged in
*
* @example
* export const createBoard = (name: string) =>
* withAuth(async (supabase, user) => {
* const { data, error } = await supabase
* .from('board')
* .insert({ name, user_id: user.id })
* if (error) throw error
* return data
* })
*/
export async function withAuth<T>(
action: (supabase: SupabaseClient<Database>, user: User) => Promise<T>,
): Promise<T> {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()

if (authError || !user) {
throw new Error('Authentication required')
}

return action(supabase, user)
}
256 changes: 131 additions & 125 deletions src/lib/actions/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import * as Sentry from '@sentry/nextjs'

import { withAuth } from '@/lib/actions/auth-guard'
import type {
StatusListDomain,
RepoCardDomain,
Expand Down Expand Up @@ -221,23 +222,23 @@ export async function updateStatusList(
if (updates.name !== undefined) statusListNameSchema.parse(updates.name)
if (updates.color !== undefined) statusListColorSchema.parse(updates.color)

const supabase = await createClient()

const updateData: Record<string, unknown> = {}
if (updates.name !== undefined) updateData.name = updates.name
if (updates.color !== undefined) updateData.color = updates.color

const { error } = await supabase
.from('statuslist')
.update(updateData)
.eq('id', statusId)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Update status list', statusId, updates },
})
throw new Error('Failed to update status list')
}
return withAuth(async (supabase) => {
const updateData: Record<string, unknown> = {}
if (updates.name !== undefined) updateData.name = updates.name
if (updates.color !== undefined) updateData.color = updates.color

const { error } = await supabase
.from('statuslist')
.update(updateData)
.eq('id', statusId)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Update status list', statusId, updates },
})
throw new Error('Failed to update status list')
}
})
}

/**
Expand Down Expand Up @@ -284,23 +285,23 @@ export async function updateStatusListPosition(
// Validate grid position (P2-4)
gridPositionSchema.parse({ gridRow, gridCol })

const supabase = await createClient()

const { error } = await supabase
.from('statuslist')
.update({
grid_row: gridRow,
grid_col: gridCol,
updated_at: new Date().toISOString(),
})
.eq('id', id)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Update status list position', id, gridRow, gridCol },
})
throw new Error('Failed to update column position')
}
return withAuth(async (supabase) => {
const { error } = await supabase
.from('statuslist')
.update({
grid_row: gridRow,
grid_col: gridCol,
updated_at: new Date().toISOString(),
})
.eq('id', id)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Update status list position', id, gridRow, gridCol },
})
throw new Error('Failed to update column position')
}
})
}

/**
Expand All @@ -319,23 +320,23 @@ export async function swapStatusListPositions(
id1: string,
id2: string,
): Promise<void> {
const supabase = await createClient()
return withAuth(async (supabase) => {
const { error } = await supabase.rpc('swap_statuslist_positions', {
id_a: id1,
id_b: id2,
})

const { error } = await supabase.rpc('swap_statuslist_positions', {
id_a: id1,
id_b: id2,
if (error) {
Sentry.captureException(error, {
extra: {
context: 'Swap status list positions (RPC)',
id1,
id2,
},
})
throw new Error('Failed to swap column positions')
}
})

if (error) {
Sentry.captureException(error, {
extra: {
context: 'Swap status list positions (RPC)',
id1,
id2,
},
})
throw new Error('Failed to swap column positions')
}
}

/**
Expand All @@ -352,26 +353,26 @@ export async function swapStatusListPositions(
export async function batchUpdateStatusListPositions(
updates: Array<{ id: string; gridRow: number; gridCol: number }>,
): Promise<void> {
const supabase = await createClient()
return withAuth(async (supabase) => {
// Atomic batch update via PostgreSQL RPC — all updates succeed or all fail
const { error } = await supabase.rpc('batch_update_statuslist_positions', {
p_updates: updates.map(({ id, gridRow, gridCol }) => ({
id,
grid_row: gridRow,
grid_col: gridCol,
})),
})

// Atomic batch update via PostgreSQL RPC — all updates succeed or all fail
const { error } = await supabase.rpc('batch_update_statuslist_positions', {
p_updates: updates.map(({ id, gridRow, gridCol }) => ({
id,
grid_row: gridRow,
grid_col: gridCol,
})),
if (error) {
Sentry.captureException(error, {
extra: {
context: 'Batch update status list positions (RPC)',
updateCount: updates.length,
},
})
throw new Error('Failed to update column positions')
}
})

if (error) {
Sentry.captureException(error, {
extra: {
context: 'Batch update status list positions (RPC)',
updateCount: updates.length,
},
})
throw new Error('Failed to update column positions')
}
}

// ========================================
Expand Down Expand Up @@ -432,23 +433,28 @@ export async function updateRepoCardPosition(
statusId: string,
order: number,
): Promise<void> {
const supabase = await createClient()

const { error } = await supabase
.from('repocard')
.update({
status_id: statusId,
order: order,
updated_at: new Date().toISOString(),
})
.eq('id', cardId)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Update repo card position', cardId, statusId, order },
})
throw new Error('Failed to update repo card position')
}
return withAuth(async (supabase) => {
const { error } = await supabase
.from('repocard')
.update({
status_id: statusId,
order: order,
updated_at: new Date().toISOString(),
})
.eq('id', cardId)

if (error) {
Sentry.captureException(error, {
extra: {
context: 'Update repo card position',
cardId,
statusId,
order,
},
})
throw new Error('Failed to update repo card position')
}
})
}

/**
Expand All @@ -458,26 +464,26 @@ export async function updateRepoCardPosition(
export async function batchUpdateRepoCardOrders(
updates: Array<{ id: string; statusId: string; order: number }>,
): Promise<void> {
const supabase = await createClient()
return withAuth(async (supabase) => {
// Atomic batch update via PostgreSQL RPC — all updates succeed or all fail
const { error } = await supabase.rpc('batch_update_repocard_orders', {
p_updates: updates.map(({ id, statusId, order }) => ({
id,
status_id: statusId,
order,
})),
})

// Atomic batch update via PostgreSQL RPC — all updates succeed or all fail
const { error } = await supabase.rpc('batch_update_repocard_orders', {
p_updates: updates.map(({ id, statusId, order }) => ({
id,
status_id: statusId,
order,
})),
if (error) {
Sentry.captureException(error, {
extra: {
context: 'Batch update repo card orders (RPC)',
updateCount: updates.length,
},
})
throw new Error('Failed to update some repo cards')
}
})

if (error) {
Sentry.captureException(error, {
extra: {
context: 'Batch update repo card orders (RPC)',
updateCount: updates.length,
},
})
throw new Error('Failed to update some repo cards')
}
}

// ========================================
Expand Down Expand Up @@ -615,16 +621,16 @@ export async function updateBoardPositions(
* Delete a board
*/
export async function deleteBoard(boardId: string): Promise<void> {
const supabase = await createClient()

const { error } = await supabase.from('board').delete().eq('id', boardId)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Delete board', boardId },
})
throw new Error('Failed to delete board')
}
return withAuth(async (supabase) => {
const { error } = await supabase.from('board').delete().eq('id', boardId)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Delete board', boardId },
})
throw new Error('Failed to delete board')
}
})
}

/**
Expand All @@ -634,19 +640,19 @@ export async function updateBoard(
boardId: string,
updates: { name?: string },
): Promise<void> {
const supabase = await createClient()

const { error } = await supabase
.from('board')
.update(updates)
.eq('id', boardId)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Update board', boardId, updates },
})
throw new Error('Failed to update board')
}
return withAuth(async (supabase) => {
const { error } = await supabase
.from('board')
.update(updates)
.eq('id', boardId)

if (error) {
Sentry.captureException(error, {
extra: { context: 'Update board', boardId, updates },
})
throw new Error('Failed to update board')
}
})
}

// ========================================
Expand Down