diff --git a/src/lib/actions/auth-guard.ts b/src/lib/actions/auth-guard.ts new file mode 100644 index 0000000..8346053 --- /dev/null +++ b/src/lib/actions/auth-guard.ts @@ -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( + action: (supabase: SupabaseClient, user: User) => Promise, +): Promise { + 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) +} diff --git a/src/lib/actions/board.ts b/src/lib/actions/board.ts index 47de5bb..4df2e3f 100644 --- a/src/lib/actions/board.ts +++ b/src/lib/actions/board.ts @@ -9,6 +9,7 @@ import * as Sentry from '@sentry/nextjs' +import { withAuth } from '@/lib/actions/auth-guard' import type { StatusListDomain, RepoCardDomain, @@ -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 = {} - 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 = {} + 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') + } + }) } /** @@ -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') + } + }) } /** @@ -319,23 +320,23 @@ export async function swapStatusListPositions( id1: string, id2: string, ): Promise { - 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') - } } /** @@ -352,26 +353,26 @@ export async function swapStatusListPositions( export async function batchUpdateStatusListPositions( updates: Array<{ id: string; gridRow: number; gridCol: number }>, ): Promise { - 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') - } } // ======================================== @@ -432,23 +433,28 @@ export async function updateRepoCardPosition( statusId: string, order: number, ): Promise { - 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') + } + }) } /** @@ -458,26 +464,26 @@ export async function updateRepoCardPosition( export async function batchUpdateRepoCardOrders( updates: Array<{ id: string; statusId: string; order: number }>, ): Promise { - 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') - } } // ======================================== @@ -615,16 +621,16 @@ export async function updateBoardPositions( * Delete a board */ export async function deleteBoard(boardId: string): Promise { - 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') + } + }) } /** @@ -634,19 +640,19 @@ export async function updateBoard( boardId: string, updates: { name?: string }, ): Promise { - 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') + } + }) } // ========================================