From e2bff5023b90558a00a31846b29cbd2afefbe585 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 11 Feb 2026 19:34:17 +0900 Subject: [PATCH] fix: extract withAuth() guard and standardize auth checks in Server Actions Closes #63 - Created `src/lib/actions/auth-guard.ts` with reusable `withAuth` HOF that handles Supabase client creation + user authentication in one place - Wrapped 16 mutating Server Actions that previously had no auth check: - board.ts: 8 functions (updateStatusList, updateStatusListPosition, swapStatusListPositions, batchUpdateStatusListPositions, updateRepoCardPosition, batchUpdateRepoCardOrders, deleteBoard, updateBoard) - project-info.ts: 4 functions (upsertProjectInfo, updateComment, updateCommentColor, deleteComment) - maintenance-project-info.ts: 4 functions (upsertMaintenanceProjectInfo, updateMaintenanceComment, updateMaintenanceCommentColor, deleteMaintenanceComment) - Input validation stays outside withAuth (fail-fast before auth overhead) - Functions that already had explicit auth checks were left unchanged --- src/lib/actions/auth-guard.ts | 60 ++++ src/lib/actions/board.ts | 282 +++++++++-------- src/lib/actions/maintenance-project-info.ts | 317 ++++++++++--------- src/lib/actions/project-info.ts | 333 ++++++++++---------- 4 files changed, 534 insertions(+), 458 deletions(-) create mode 100644 src/lib/actions/auth-guard.ts 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 7103984..a107e35 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 + 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) + 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') - } + 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) + 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') - } + 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,35 +353,35 @@ export async function swapStatusListPositions( export async function batchUpdateStatusListPositions( updates: Array<{ id: string; gridRow: number; gridCol: number }>, ): Promise { - const supabase = await createClient() - - // Use parallel updates for performance - const updatePromises = updates.map(({ id, gridRow, gridCol }) => - supabase - .from('statuslist') - .update({ - grid_row: gridRow, - grid_col: gridCol, - updated_at: new Date().toISOString(), - }) - .eq('id', id), - ) - - const results = await Promise.all(updatePromises) + return withAuth(async (supabase) => { + // Use parallel updates for performance + const updatePromises = updates.map(({ id, gridRow, gridCol }) => + supabase + .from('statuslist') + .update({ + grid_row: gridRow, + grid_col: gridCol, + updated_at: new Date().toISOString(), + }) + .eq('id', id), + ) - const errorResults = results.filter((r) => r.error) - if (errorResults.length > 0) { - Sentry.captureException( - new Error('Failed to batch update status list positions'), - { - extra: { - context: 'Batch update status list positions', - errors: errorResults, + const results = await Promise.all(updatePromises) + + const errorResults = results.filter((r) => r.error) + if (errorResults.length > 0) { + Sentry.captureException( + new Error('Failed to batch update status list positions'), + { + extra: { + context: 'Batch update status list positions', + errors: errorResults, + }, }, - }, - ) - throw new Error('Failed to update column positions') - } + ) + throw new Error('Failed to update column positions') + } + }) } // ======================================== @@ -441,23 +442,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) + 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') - } + if (error) { + Sentry.captureException(error, { + extra: { + context: 'Update repo card position', + cardId, + statusId, + order, + }, + }) + throw new Error('Failed to update repo card position') + } + }) } /** @@ -467,35 +473,35 @@ export async function updateRepoCardPosition( export async function batchUpdateRepoCardOrders( updates: Array<{ id: string; statusId: string; order: number }>, ): Promise { - const supabase = await createClient() - - // Use a transaction-like approach with Promise.all - const updatePromises = updates.map(({ id, statusId, order }) => - supabase - .from('repocard') - .update({ - status_id: statusId, - order: order, - updated_at: new Date().toISOString(), - }) - .eq('id', id), - ) - - const results = await Promise.all(updatePromises) + return withAuth(async (supabase) => { + // Use a transaction-like approach with Promise.all + const updatePromises = updates.map(({ id, statusId, order }) => + supabase + .from('repocard') + .update({ + status_id: statusId, + order: order, + updated_at: new Date().toISOString(), + }) + .eq('id', id), + ) - const errorResults = results.filter((r) => r.error) - if (errorResults.length > 0) { - Sentry.captureException( - new Error('Failed to batch update repo card orders'), - { - extra: { - context: 'Batch update repo card orders', - errors: errorResults, + const results = await Promise.all(updatePromises) + + const errorResults = results.filter((r) => r.error) + if (errorResults.length > 0) { + Sentry.captureException( + new Error('Failed to batch update repo card orders'), + { + extra: { + context: 'Batch update repo card orders', + errors: errorResults, + }, }, - }, - ) - throw new Error('Failed to update some repo cards') - } + ) + throw new Error('Failed to update some repo cards') + } + }) } // ======================================== @@ -635,16 +641,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) + 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') - } + if (error) { + Sentry.captureException(error, { + extra: { context: 'Delete board', boardId }, + }) + throw new Error('Failed to delete board') + } + }) } /** @@ -654,19 +660,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') + } + }) } // ======================================== diff --git a/src/lib/actions/maintenance-project-info.ts b/src/lib/actions/maintenance-project-info.ts index aa210eb..dd06794 100644 --- a/src/lib/actions/maintenance-project-info.ts +++ b/src/lib/actions/maintenance-project-info.ts @@ -11,6 +11,7 @@ import * as Sentry from '@sentry/nextjs' +import { withAuth } from '@/lib/actions/auth-guard' import { createClient } from '@/lib/supabase/server' import type { TablesInsert, @@ -166,9 +167,7 @@ export async function upsertMaintenanceProjectInfo( maintenanceId: string, data: ProjectInfoData, ): Promise { - const supabase = await createClient() - - // Validation + // Validation (before auth - fail fast on invalid input) validateNote(data.note) validateComment(data.comment) data.links.forEach((link) => { @@ -181,57 +180,65 @@ export async function upsertMaintenanceProjectInfo( .filter((link) => link.url && link.url.trim() !== '') .map((link) => ({ type: link.type, url: link.url })) - try { - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('maintenance_id', maintenanceId) - .single<{ id: string }>() - - if (existingInfo) { - const updateData: ProjectInfoUpdate = { - note: data.note, - comment: data.comment, - links: linksArray, - updated_at: new Date().toISOString(), - } - - const { error: updateError } = await supabase + return withAuth(async (supabase) => { + try { + const { data: existingInfo } = await supabase .from('projectinfo') - .update(updateData) - .eq('id', existingInfo.id) - - if (updateError) { - Sentry.captureException(updateError, { - extra: { context: 'Update maintenance project info', maintenanceId }, - }) - throw new Error('Failed to update project information') - } - } else { - const insertData: ProjectInfoInsert = { - maintenance_id: maintenanceId, - note: data.note, - comment: data.comment, - links: linksArray, + .select('id') + .eq('maintenance_id', maintenanceId) + .single<{ id: string }>() + + if (existingInfo) { + const updateData: ProjectInfoUpdate = { + note: data.note, + comment: data.comment, + links: linksArray, + updated_at: new Date().toISOString(), + } + + const { error: updateError } = await supabase + .from('projectinfo') + .update(updateData) + .eq('id', existingInfo.id) + + if (updateError) { + Sentry.captureException(updateError, { + extra: { + context: 'Update maintenance project info', + maintenanceId, + }, + }) + throw new Error('Failed to update project information') + } + } else { + const insertData: ProjectInfoInsert = { + maintenance_id: maintenanceId, + note: data.note, + comment: data.comment, + links: linksArray, + } + + const { error: createError } = await supabase + .from('projectinfo') + .insert(insertData) + + if (createError) { + Sentry.captureException(createError, { + extra: { + context: 'Create maintenance project info', + maintenanceId, + }, + }) + throw new Error('Failed to create project information') + } } - - const { error: createError } = await supabase - .from('projectinfo') - .insert(insertData) - - if (createError) { - Sentry.captureException(createError, { - extra: { context: 'Create maintenance project info', maintenanceId }, - }) - throw new Error('Failed to create project information') + } catch (error) { + if (error instanceof Error) { + throw error } + throw new Error('An error occurred while saving project information') } - } catch (error) { - if (error instanceof Error) { - throw error - } - throw new Error('An error occurred while saving project information') - } + }) } /** @@ -308,55 +315,56 @@ export async function updateMaintenanceComment( comment: string, color?: CommentColor, ): Promise { - const supabase = await createClient() - + // Validate (before auth - fail fast) validateComment(comment) if (color) { validateCommentColor(color) } - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('maintenance_id', maintenanceId) - .single<{ id: string }>() + return withAuth(async (supabase) => { + const { data: existingInfo } = await supabase + .from('projectinfo') + .select('id') + .eq('maintenance_id', maintenanceId) + .single<{ id: string }>() - if (existingInfo) { - const updateData: ProjectInfoUpdate = { - comment, - updated_at: new Date().toISOString(), - } - if (color) { - updateData.comment_color = color - } + if (existingInfo) { + const updateData: ProjectInfoUpdate = { + comment, + updated_at: new Date().toISOString(), + } + if (color) { + updateData.comment_color = color + } - const { error } = await supabase - .from('projectinfo') - .update(updateData) - .eq('id', existingInfo.id) + const { error } = await supabase + .from('projectinfo') + .update(updateData) + .eq('id', existingInfo.id) - if (error) { - Sentry.captureException(error, { - extra: { context: 'Update maintenance comment', maintenanceId }, + if (error) { + Sentry.captureException(error, { + extra: { context: 'Update maintenance comment', maintenanceId }, + }) + throw new Error('Failed to update comment') + } + } else { + const { error } = await supabase.from('projectinfo').insert({ + maintenance_id: maintenanceId, + comment, + comment_color: color || DEFAULT_COMMENT_COLOR, + note: '', + links: [], }) - throw new Error('Failed to update comment') - } - } else { - const { error } = await supabase.from('projectinfo').insert({ - maintenance_id: maintenanceId, - comment, - comment_color: color || DEFAULT_COMMENT_COLOR, - note: '', - links: [], - }) - if (error) { - Sentry.captureException(error, { - extra: { context: 'Create maintenance comment', maintenanceId }, - }) - throw new Error('Failed to save comment') + if (error) { + Sentry.captureException(error, { + extra: { context: 'Create maintenance comment', maintenanceId }, + }) + throw new Error('Failed to save comment') + } } - } + }) } /** @@ -372,55 +380,56 @@ export async function updateMaintenanceCommentColor( maintenanceId: string, color: CommentColor, ): Promise { - const supabase = await createClient() - + // Validate (before auth - fail fast) validateCommentColor(color) - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('maintenance_id', maintenanceId) - .single<{ id: string }>() - - if (existingInfo) { - const { error } = await supabase + return withAuth(async (supabase) => { + const { data: existingInfo } = await supabase .from('projectinfo') - .update({ - comment_color: color, - updated_at: new Date().toISOString(), - }) - .eq('id', existingInfo.id) + .select('id') + .eq('maintenance_id', maintenanceId) + .single<{ id: string }>() - if (error) { - Sentry.captureException(error, { - extra: { - context: 'Update maintenance comment color', - maintenanceId, - color, - }, - }) - throw new Error('Failed to update comment color') - } - } else { - const { error } = await supabase.from('projectinfo').insert({ - maintenance_id: maintenanceId, - comment: null, - comment_color: color, - note: '', - links: [], - }) + if (existingInfo) { + const { error } = await supabase + .from('projectinfo') + .update({ + comment_color: color, + updated_at: new Date().toISOString(), + }) + .eq('id', existingInfo.id) - if (error) { - Sentry.captureException(error, { - extra: { - context: 'Create maintenance projectinfo for color', - maintenanceId, - color, - }, + if (error) { + Sentry.captureException(error, { + extra: { + context: 'Update maintenance comment color', + maintenanceId, + color, + }, + }) + throw new Error('Failed to update comment color') + } + } else { + const { error } = await supabase.from('projectinfo').insert({ + maintenance_id: maintenanceId, + comment: null, + comment_color: color, + note: '', + links: [], }) - throw new Error('Failed to save comment color') + + if (error) { + Sentry.captureException(error, { + extra: { + context: 'Create maintenance projectinfo for color', + maintenanceId, + color, + }, + }) + throw new Error('Failed to save comment color') + } } - } + }) } /** @@ -437,31 +446,31 @@ export async function updateMaintenanceCommentColor( export async function deleteMaintenanceComment( maintenanceId: string, ): Promise { - const supabase = await createClient() - - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('maintenance_id', maintenanceId) - .single<{ id: string }>() + return withAuth(async (supabase) => { + const { data: existingInfo } = await supabase + .from('projectinfo') + .select('id') + .eq('maintenance_id', maintenanceId) + .single<{ id: string }>() - if (!existingInfo) { - return - } + if (!existingInfo) { + return + } - const { error } = await supabase - .from('projectinfo') - .update({ - comment: null, - comment_color: DEFAULT_COMMENT_COLOR, - updated_at: new Date().toISOString(), - }) - .eq('id', existingInfo.id) + const { error } = await supabase + .from('projectinfo') + .update({ + comment: null, + comment_color: DEFAULT_COMMENT_COLOR, + updated_at: new Date().toISOString(), + }) + .eq('id', existingInfo.id) - if (error) { - Sentry.captureException(error, { - extra: { context: 'Delete maintenance comment', maintenanceId }, - }) - throw new Error('Failed to delete comment') - } + if (error) { + Sentry.captureException(error, { + extra: { context: 'Delete maintenance comment', maintenanceId }, + }) + throw new Error('Failed to delete comment') + } + }) } diff --git a/src/lib/actions/project-info.ts b/src/lib/actions/project-info.ts index 56f6491..3c5d9e2 100644 --- a/src/lib/actions/project-info.ts +++ b/src/lib/actions/project-info.ts @@ -17,6 +17,7 @@ import * as Sentry from '@sentry/nextjs' +import { withAuth } from '@/lib/actions/auth-guard' import { createClient } from '@/lib/supabase/server' import type { TablesInsert, @@ -228,9 +229,7 @@ export async function upsertProjectInfo( repoCardId: string, data: ProjectInfoData, ): Promise { - const supabase = await createClient() - - // Validation + // Validation (before auth - fail fast on invalid input) validateNote(data.note) validateComment(data.comment) data.links.forEach((link) => { @@ -251,63 +250,65 @@ export async function upsertProjectInfo( .filter((link) => link.url && link.url.trim() !== '') .map((link) => ({ type: link.type, url: link.url })) - try { - // project_info upsert - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('repo_card_id', repoCardId) - .single<{ id: string }>() - - if (existingInfo) { - // Update - const updateData: ProjectInfoUpdate = { - note: data.note, - comment: data.comment, - links: linksArray, - updated_at: new Date().toISOString(), - } - - const { error: updateError } = await supabase + return withAuth(async (supabase) => { + try { + // project_info upsert + const { data: existingInfo } = await supabase .from('projectinfo') - .update(updateData) - .eq('id', existingInfo.id) - - if (updateError) { - Sentry.captureException(updateError, { - extra: { context: 'Update project info', repoCardId }, - }) - throw new Error('Failed to update project information') - } - } else { - // Create new - const insertData: ProjectInfoInsert = { - repo_card_id: repoCardId, - note: data.note, - comment: data.comment, - links: linksArray, + .select('id') + .eq('repo_card_id', repoCardId) + .single<{ id: string }>() + + if (existingInfo) { + // Update + const updateData: ProjectInfoUpdate = { + note: data.note, + comment: data.comment, + links: linksArray, + updated_at: new Date().toISOString(), + } + + const { error: updateError } = await supabase + .from('projectinfo') + .update(updateData) + .eq('id', existingInfo.id) + + if (updateError) { + Sentry.captureException(updateError, { + extra: { context: 'Update project info', repoCardId }, + }) + throw new Error('Failed to update project information') + } + } else { + // Create new + const insertData: ProjectInfoInsert = { + repo_card_id: repoCardId, + note: data.note, + comment: data.comment, + links: linksArray, + } + + const { error: createError } = await supabase + .from('projectinfo') + .insert(insertData) + + if (createError) { + Sentry.captureException(createError, { + extra: { context: 'Create project info', repoCardId }, + }) + throw new Error('Failed to create project information') + } } - const { error: createError } = await supabase - .from('projectinfo') - .insert(insertData) - - if (createError) { - Sentry.captureException(createError, { - extra: { context: 'Create project info', repoCardId }, - }) - throw new Error('Failed to create project information') + // Note: No revalidatePath needed - Next.js v16 doesn't cache Supabase requests + // Client handles state updates via Redux optimistic updates + } catch (error) { + if (error instanceof Error) { + throw error } + throw new Error('An error occurred while saving project information') } - - // Note: No revalidatePath needed - Next.js v16 doesn't cache Supabase requests - // Client handles state updates via Redux optimistic updates - } catch (error) { - if (error instanceof Error) { - throw error - } - throw new Error('An error occurred while saving project information') - } + }) } /** @@ -391,62 +392,62 @@ export async function updateComment( comment: string, color?: CommentColor, ): Promise { - const supabase = await createClient() - - // Validate + // Validate (before auth - fail fast) validateComment(comment) if (color) { validateCommentColor(color) } - // Check if projectinfo exists - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('repo_card_id', repoCardId) - .single<{ id: string }>() + return withAuth(async (supabase) => { + // Check if projectinfo exists + const { data: existingInfo } = await supabase + .from('projectinfo') + .select('id') + .eq('repo_card_id', repoCardId) + .single<{ id: string }>() - if (existingInfo) { - // Update existing - const updateData: ProjectInfoUpdate = { - comment, - updated_at: new Date().toISOString(), - } - // Only update color if provided - if (color) { - updateData.comment_color = color - } + if (existingInfo) { + // Update existing + const updateData: ProjectInfoUpdate = { + comment, + updated_at: new Date().toISOString(), + } + // Only update color if provided + if (color) { + updateData.comment_color = color + } - const { error } = await supabase - .from('projectinfo') - .update(updateData) - .eq('id', existingInfo.id) + const { error } = await supabase + .from('projectinfo') + .update(updateData) + .eq('id', existingInfo.id) - if (error) { - Sentry.captureException(error, { - extra: { context: 'Update comment', repoCardId }, + if (error) { + Sentry.captureException(error, { + extra: { context: 'Update comment', repoCardId }, + }) + throw new Error('Failed to update comment') + } + } else { + // Create new projectinfo with just the comment + const { error } = await supabase.from('projectinfo').insert({ + repo_card_id: repoCardId, + comment, + comment_color: color || DEFAULT_COMMENT_COLOR, + note: '', + links: [], }) - throw new Error('Failed to update comment') - } - } else { - // Create new projectinfo with just the comment - const { error } = await supabase.from('projectinfo').insert({ - repo_card_id: repoCardId, - comment, - comment_color: color || DEFAULT_COMMENT_COLOR, - note: '', - links: [], - }) - if (error) { - Sentry.captureException(error, { - extra: { context: 'Create comment', repoCardId }, - }) - throw new Error('Failed to save comment') + if (error) { + Sentry.captureException(error, { + extra: { context: 'Create comment', repoCardId }, + }) + throw new Error('Failed to save comment') + } } - } - // Note: No revalidatePath needed - client handles state via Redux + // Note: No revalidatePath needed - client handles state via Redux + }) } /** @@ -466,53 +467,53 @@ export async function updateCommentColor( repoCardId: string, color: CommentColor, ): Promise { - const supabase = await createClient() - - // Validate + // Validate (before auth - fail fast) validateCommentColor(color) - // Check if projectinfo exists - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('repo_card_id', repoCardId) - .single<{ id: string }>() - - if (existingInfo) { - // Update existing - const { error } = await supabase + return withAuth(async (supabase) => { + // Check if projectinfo exists + const { data: existingInfo } = await supabase .from('projectinfo') - .update({ - comment_color: color, - updated_at: new Date().toISOString(), - }) - .eq('id', existingInfo.id) + .select('id') + .eq('repo_card_id', repoCardId) + .single<{ id: string }>() - if (error) { - Sentry.captureException(error, { - extra: { context: 'Update comment color', repoCardId, color }, - }) - throw new Error('Failed to update comment color') - } - } else { - // Create new projectinfo with just the color (no comment text) - const { error } = await supabase.from('projectinfo').insert({ - repo_card_id: repoCardId, - comment: null, - comment_color: color, - note: '', - links: [], - }) + if (existingInfo) { + // Update existing + const { error } = await supabase + .from('projectinfo') + .update({ + comment_color: color, + updated_at: new Date().toISOString(), + }) + .eq('id', existingInfo.id) - if (error) { - Sentry.captureException(error, { - extra: { context: 'Create projectinfo for color', repoCardId, color }, + if (error) { + Sentry.captureException(error, { + extra: { context: 'Update comment color', repoCardId, color }, + }) + throw new Error('Failed to update comment color') + } + } else { + // Create new projectinfo with just the color (no comment text) + const { error } = await supabase.from('projectinfo').insert({ + repo_card_id: repoCardId, + comment: null, + comment_color: color, + note: '', + links: [], }) - throw new Error('Failed to save comment color') + + if (error) { + Sentry.captureException(error, { + extra: { context: 'Create projectinfo for color', repoCardId, color }, + }) + throw new Error('Failed to save comment color') + } } - } - // Note: No revalidatePath needed - client handles state via Redux + // Note: No revalidatePath needed - client handles state via Redux + }) } /** @@ -527,36 +528,36 @@ export async function updateCommentColor( * await deleteComment('card-1') */ export async function deleteComment(repoCardId: string): Promise { - const supabase = await createClient() - - // Check if projectinfo exists - const { data: existingInfo } = await supabase - .from('projectinfo') - .select('id') - .eq('repo_card_id', repoCardId) - .single<{ id: string }>() + return withAuth(async (supabase) => { + // Check if projectinfo exists + const { data: existingInfo } = await supabase + .from('projectinfo') + .select('id') + .eq('repo_card_id', repoCardId) + .single<{ id: string }>() - if (!existingInfo) { - // Nothing to delete - return - } + if (!existingInfo) { + // Nothing to delete + return + } - // Clear comment and reset color to default - const { error } = await supabase - .from('projectinfo') - .update({ - comment: null, - comment_color: DEFAULT_COMMENT_COLOR, - updated_at: new Date().toISOString(), - }) - .eq('id', existingInfo.id) + // Clear comment and reset color to default + const { error } = await supabase + .from('projectinfo') + .update({ + comment: null, + comment_color: DEFAULT_COMMENT_COLOR, + updated_at: new Date().toISOString(), + }) + .eq('id', existingInfo.id) - if (error) { - Sentry.captureException(error, { - extra: { context: 'Delete comment', repoCardId }, - }) - throw new Error('Failed to delete comment') - } + if (error) { + Sentry.captureException(error, { + extra: { context: 'Delete comment', repoCardId }, + }) + throw new Error('Failed to delete comment') + } - // Note: No revalidatePath needed - client handles state via Redux + // Note: No revalidatePath needed - client handles state via Redux + }) }