diff --git a/tavla/app/(admin)/actions.ts b/tavla/app/(admin)/actions.ts index 2720470ac..9c71cf857 100644 --- a/tavla/app/(admin)/actions.ts +++ b/tavla/app/(admin)/actions.ts @@ -9,10 +9,10 @@ import { import { getUser, initializeAdminApp } from './utils/firebase' import { getUserFromSessionCookie } from './utils/server' import { chunk, concat, isEmpty, flattenDeep } from 'lodash' -import { TavlaError } from './utils/types' import { redirect } from 'next/navigation' import { FIREBASE_DEV_CONFIG, FIREBASE_PRD_CONFIG } from './utils/constants' import { userInOrganization } from './utils' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -24,7 +24,18 @@ export async function getFirebaseClientConfig() { export async function getOrganizationIfUserHasAccess(oid?: TOrganizationID) { if (!oid) return undefined - const doc = await firestore().collection('organizations').doc(oid).get() + + let doc = null + + try { + doc = await firestore().collection('organizations').doc(oid).get() + if (!doc) throw Error('Fetch org returned null or undefined') + } catch (error) { + Sentry.captureMessage( + 'Error while fetching organization from firestore, orgID: ' + oid, + ) + throw error + } const organization = { ...doc.data(), id: doc.id } as TOrganization const user = await getUserFromSessionCookie() @@ -37,22 +48,29 @@ export async function getOrganizationsForUser() { const user = await getUserFromSessionCookie() if (!user) return redirect('/') - const owner = firestore() - .collection('organizations') - .where('owners', 'array-contains', user.uid) - .get() - - const editor = firestore() - .collection('organizations') - .where('editors', 'array-contains', user.uid) - .get() - - const queries = await Promise.all([owner, editor]) - return queries - .map((q) => - q.docs.map((d) => ({ ...d.data(), id: d.id }) as TOrganization), + try { + const owner = firestore() + .collection('organizations') + .where('owners', 'array-contains', user.uid) + .get() + + const editor = firestore() + .collection('organizations') + .where('editors', 'array-contains', user.uid) + .get() + + const queries = await Promise.all([owner, editor]) + return queries + .map((q) => + q.docs.map((d) => ({ ...d.data(), id: d.id }) as TOrganization), + ) + .flat() + } catch (error) { + Sentry.captureMessage( + 'Error while fetching organizations for user with id ' + user.uid, ) - .flat() + throw error + } } export async function getBoardsForOrganization(oid: TOrganizationID) { @@ -64,69 +82,55 @@ export async function getBoardsForOrganization(oid: TOrganizationID) { const batchedBoardIDs = chunk(boards, 20) - const boardQueries = batchedBoardIDs.map((batch) => - firestore() - .collection('boards') - .where(firestore.FieldPath.documentId(), 'in', batch) - .get(), - ) - - const boardRefs = await Promise.all(boardQueries) - - return boardRefs - .map((ref) => - ref.docs.map((doc) => ({ id: doc.id, ...doc.data() }) as TBoard), + try { + const boardQueries = batchedBoardIDs.map((batch) => + firestore() + .collection('boards') + .where(firestore.FieldPath.documentId(), 'in', batch) + .get(), ) - .flat() -} - -export async function getPrivateBoardsForUser() { - const user = await getUser() - if (!user) - throw new TavlaError({ - code: 'NOT_FOUND', - message: `Found no user`, - }) - - const boardIDs = concat(user.owner ?? [], user.editor ?? []) - - if (isEmpty(boardIDs)) return [] - - const batchedBoardIDs = chunk(boardIDs, 20) - - const boardQueries = batchedBoardIDs.map((batch) => - firestore() - .collection('boards') - .where(firestore.FieldPath.documentId(), 'in', batch) - .get(), - ) - - const boardRefs = await Promise.all(boardQueries) - return boardRefs - .map((ref) => - ref.docs.map((doc) => ({ id: doc.id, ...doc.data() }) as TBoard), + const boardRefs = await Promise.all(boardQueries) + + return boardRefs + .map((ref) => + ref.docs.map( + (doc) => ({ id: doc.id, ...doc.data() }) as TBoard, + ), + ) + .flat() + } catch (error) { + Sentry.captureMessage( + 'Error while fetching boards for organization with orgID ' + oid, ) - .flat() + throw error + } } export async function getBoards(ids?: TBoardID[]) { if (!ids) return [] const batches = chunk(ids, 20) - const queries = batches.map((batch) => - firestore() - .collection('boards') - .where(firestore.FieldPath.documentId(), 'in', batch) - .get(), - ) - - const refs = await Promise.all(queries) - return refs - .map((ref) => - ref.docs.map((doc) => ({ id: doc.id, ...doc.data() }) as TBoard), + try { + const queries = batches.map((batch) => + firestore() + .collection('boards') + .where(firestore.FieldPath.documentId(), 'in', batch) + .get(), ) - .flat() + + const refs = await Promise.all(queries) + return refs + .map((ref) => + ref.docs.map( + (doc) => ({ id: doc.id, ...doc.data() }) as TBoard, + ), + ) + .flat() + } catch (error) { + Sentry.captureMessage('Error while fetching list of boards: ' + ids) + throw error + } } export async function getAllBoardsForUser() { diff --git a/tavla/app/(admin)/boards/components/TagModal/actions.ts b/tavla/app/(admin)/boards/components/TagModal/actions.ts index 6799a1e5d..6737ca975 100644 --- a/tavla/app/(admin)/boards/components/TagModal/actions.ts +++ b/tavla/app/(admin)/boards/components/TagModal/actions.ts @@ -1,7 +1,6 @@ 'use server' import { TFormFeedback, getFormFeedbackForError } from 'app/(admin)/utils' -import { FirebaseError } from 'firebase/app' -import { isString, uniq } from 'lodash' +import { uniq } from 'lodash' import { revalidatePath } from 'next/cache' import { hasBoardEditorAccess } from 'app/(admin)/utils/firebase' import { TBoardID } from 'types/settings' @@ -9,7 +8,9 @@ import { firestore } from 'firebase-admin' import { TTag } from 'types/meta' import { isEmptyOrSpaces } from 'app/(admin)/edit/utils' import { getBoard } from 'Board/scenarios/Board/firebase' -import { notFound } from 'next/navigation' +import { notFound, redirect } from 'next/navigation' +import * as Sentry from '@sentry/nextjs' +import { handleError } from 'app/(admin)/utils/handleError' async function fetchTags({ bid }: { bid: TBoardID }) { const board = await getBoard(bid) @@ -18,7 +19,9 @@ async function fetchTags({ bid }: { bid: TBoardID }) { } const access = await hasBoardEditorAccess(board.id) - if (!access) throw 'auth/operation-not-allowed' + if (!access) { + redirect('/') + } return (board?.meta?.tags as TTag[]) ?? [] } @@ -32,18 +35,23 @@ export async function removeTag( const access = await hasBoardEditorAccess(bid) if (!access) throw 'auth/operation-not-allowed' + const tags = await fetchTags({ bid }) try { - const tags = await fetchTags({ bid }) await firestore() .collection('boards') .doc(bid) .update({ 'meta.tags': tags.filter((t) => t !== tag) }) revalidatePath('/') - } catch (e) { - if (e instanceof FirebaseError || isString(e)) - return getFormFeedbackForError(e) - return getFormFeedbackForError('general') + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while removing tag from firestore', + boardID: bid, + tagValue: tag, + }, + }) + return handleError(error) } } @@ -60,9 +68,9 @@ export async function addTag( const access = await hasBoardEditorAccess(bid) if (!access) throw 'auth/operation-not-allowed' - try { - const tags = await fetchTags({ bid }) + const tags = await fetchTags({ bid }) + try { if (tags.map((t) => t.toUpperCase()).includes(tag.toUpperCase())) throw 'boards/tag-exists' @@ -71,9 +79,14 @@ export async function addTag( .doc(bid) .update({ 'meta.tags': uniq([...tags, tag]).sort() }) revalidatePath('/') - } catch (e) { - if (e instanceof FirebaseError || isString(e)) - return getFormFeedbackForError(e) - return getFormFeedbackForError('general') + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while adding new tag to firestore', + boardID: bid, + tagValue: tag, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/boards/utils/actions.ts b/tavla/app/(admin)/boards/utils/actions.ts index 7a437ca0a..8dfec9d71 100644 --- a/tavla/app/(admin)/boards/utils/actions.ts +++ b/tavla/app/(admin)/boards/utils/actions.ts @@ -1,10 +1,9 @@ 'use server' -import { TFormFeedback, getFormFeedbackForError } from 'app/(admin)/utils' -import { FirebaseError } from 'firebase/app' -import { isString } from 'lodash' +import { TFormFeedback } from 'app/(admin)/utils' import { revalidatePath } from 'next/cache' import { deleteBoard, initializeAdminApp } from 'app/(admin)/utils/firebase' import { redirect } from 'next/navigation' +import { handleError } from 'app/(admin)/utils/handleError' initializeAdminApp() @@ -18,9 +17,7 @@ export async function deleteBoardAction( await deleteBoard(bid) revalidatePath('/') } catch (e) { - if (e instanceof FirebaseError || isString(e)) - return getFormFeedbackForError(e) - return getFormFeedbackForError('general') + return handleError(e) } redirect('/boards') } diff --git a/tavla/app/(admin)/components/CreateBoard/actions.ts b/tavla/app/(admin)/components/CreateBoard/actions.ts index d2780beaa..390d2634e 100644 --- a/tavla/app/(admin)/components/CreateBoard/actions.ts +++ b/tavla/app/(admin)/components/CreateBoard/actions.ts @@ -2,11 +2,12 @@ import { getOrganizationIfUserHasAccess } from 'app/(admin)/actions' import { TFormFeedback, getFormFeedbackForError } from 'app/(admin)/utils' import { initializeAdminApp } from 'app/(admin)/utils/firebase' +import { handleError } from 'app/(admin)/utils/handleError' import { getUserFromSessionCookie } from 'app/(admin)/utils/server' import admin, { firestore } from 'firebase-admin' -import { FirebaseError } from 'firebase/app' import { redirect } from 'next/navigation' import { TBoard, TOrganization, TOrganizationID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -35,24 +36,26 @@ export async function createBoard( let organization: TOrganization | undefined if (oid) organization = await getOrganizationIfUserHasAccess(oid) - const createdBoard = await firestore() - .collection('boards') - .add({ - ...board, - meta: { - ...board.meta, - fontSize: - board.meta?.fontSize ?? - organization?.defaults?.font ?? - 'medium', - created: Date.now(), - dateModified: Date.now(), - }, - }) - - if (!createdBoard) return getFormFeedbackForError('firebase/general') + let createdBoard = null try { + createdBoard = await firestore() + .collection('boards') + .add({ + ...board, + meta: { + ...board.meta, + fontSize: + board.meta?.fontSize ?? + organization?.defaults?.font ?? + 'medium', + created: Date.now(), + dateModified: Date.now(), + }, + }) + + if (!createdBoard) return getFormFeedbackForError('firebase/general') + firestore() .collection(oid ? 'organizations' : 'users') .doc(oid ? String(oid) : String(user.uid)) @@ -60,9 +63,16 @@ export async function createBoard( [oid ? 'boards' : 'owner']: admin.firestore.FieldValue.arrayUnion(createdBoard.id), }) - } catch (e) { - if (e instanceof FirebaseError) return getFormFeedbackForError(e) - return getFormFeedbackForError('firebase/general') + } catch (error) { + Sentry.captureException(error, { + extra: { + message: + 'Error while adding newly created board to either user or org', + userID: user.uid, + orgID: oid, + }, + }) + return handleError(error) } redirect(`/edit/${createdBoard.id}`) diff --git a/tavla/app/(admin)/components/Login/Create.tsx b/tavla/app/(admin)/components/Login/Create.tsx index 436a06eb2..8ffbb4298 100644 --- a/tavla/app/(admin)/components/Login/Create.tsx +++ b/tavla/app/(admin)/components/Login/Create.tsx @@ -16,13 +16,13 @@ import { getFormFeedbackForError, getFormFeedbackForField, } from 'app/(admin)/utils' -import { FirebaseError } from 'firebase/app' import { FormError } from '../FormError' import { SubmitButton } from 'components/Form/SubmitButton' import { usePathname } from 'next/navigation' import { Button, ButtonGroup } from '@entur/button' import Link from 'next/link' import ClientOnlyTextField from 'app/components/NoSSR/TextField' +import { handleError } from 'app/(admin)/utils/handleError' function Create() { const submit = async (p: TFormFeedback | undefined, data: FormData) => { @@ -44,9 +44,7 @@ function Create() { await sendEmailVerification(credential.user) return getFormFeedbackForError('auth/create', email) } catch (e) { - if (e instanceof FirebaseError) { - return getFormFeedbackForError(e) - } + return handleError(e) } } const [state, action] = useActionState(submit, undefined) diff --git a/tavla/app/(admin)/components/Login/actions.ts b/tavla/app/(admin)/components/Login/actions.ts index 6a946f787..b4499a034 100644 --- a/tavla/app/(admin)/components/Login/actions.ts +++ b/tavla/app/(admin)/components/Login/actions.ts @@ -5,6 +5,7 @@ import { TUserID } from 'types/settings' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { initializeAdminApp } from 'app/(admin)/utils/firebase' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -35,5 +36,15 @@ export async function login(token: string) { } export async function create(uid: TUserID) { - await firestore().collection('users').doc(uid).create({}) + try { + await firestore().collection('users').doc(uid).create({}) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while creating new user', + userID: uid, + }, + }) + throw error + } } diff --git a/tavla/app/(admin)/components/TileSelector/utils.ts b/tavla/app/(admin)/components/TileSelector/utils.ts index 3d53a2baa..3364904f7 100644 --- a/tavla/app/(admin)/components/TileSelector/utils.ts +++ b/tavla/app/(admin)/components/TileSelector/utils.ts @@ -9,6 +9,7 @@ import { DEFAULT_ORGANIZATION_COLUMNS } from 'types/column' import { TCoordinate } from 'types/meta' import { TOrganization } from 'types/settings' import { TTile } from 'types/tile' +import * as Sentry from '@sentry/nextjs' export function formDataToTile(data: FormData, organization?: TOrganization) { const quayId = data.get('quay') as string @@ -43,8 +44,14 @@ export async function getWalkingDistance(from: TCoordinate, to: TCoordinate) { }, }) return response.trip.tripPatterns[0]?.duration - } catch { - throw new Error('Failed to get walking distance') + } catch (error) { + Sentry.captureMessage( + 'getWalkingDistance failed with from-coordinates ' + + from + + ' and to-coordinates' + + to, + ) + throw error } } @@ -57,8 +64,11 @@ export async function getStopPlaceCoordinates(stopPlaceId: string) { lat: response.stopPlace?.latitude ?? 0, lng: response.stopPlace?.longitude ?? 0, } as TCoordinate - } catch { - throw new Error('Failed to get stop place coordinates') + } catch (error) { + Sentry.captureMessage( + 'getStopPlaceCoordinates failed for stopPlaceId ' + stopPlaceId, + ) + throw error } } @@ -71,7 +81,8 @@ export async function getQuayCoordinates(quayId: string) { lat: response.quay?.latitude ?? 0, lng: response.quay?.longitude ?? 0, } as TCoordinate - } catch { - throw new Error('Failed to get quay coordinates') + } catch (error) { + Sentry.captureMessage('getQuayCoordinates failed for quayId' + quayId) + throw error } } diff --git a/tavla/app/(admin)/edit/[id]/actions.ts b/tavla/app/(admin)/edit/[id]/actions.ts index de4be7106..4fc011b06 100644 --- a/tavla/app/(admin)/edit/[id]/actions.ts +++ b/tavla/app/(admin)/edit/[id]/actions.ts @@ -14,6 +14,7 @@ import { import { TCoordinate, TLocation } from 'types/meta' import { revalidatePath } from 'next/cache' import { TBoardID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -21,13 +22,20 @@ export async function addTile(bid: TBoardID, tile: TTile) { const access = await hasBoardEditorAccess(bid) if (!access) return redirect('/') - await firestore() - .collection('boards') - .doc(bid) - .update({ - tiles: firestore.FieldValue.arrayUnion(tile), - 'meta.dateModified': Date.now(), - }) + try { + await firestore() + .collection('boards') + .doc(bid) + .update({ + tiles: firestore.FieldValue.arrayUnion(tile), + 'meta.dateModified': Date.now(), + }) + } catch (error) { + Sentry.captureMessage( + 'Failed to save tile to board in firestore. BoardID: ' + bid, + ) + throw error + } } export async function getWalkingDistanceTile( @@ -63,13 +71,24 @@ export async function getWalkingDistanceTile( }, } } -export async function saveTiles(bid: TBoardID, tiles: TTile[]) { +export async function saveUpdatedTileOrder(bid: TBoardID, tiles: TTile[]) { const access = await hasBoardEditorAccess(bid) if (!access) return redirect('/') - await firestore().collection('boards').doc(bid).update({ - tiles: tiles, - 'meta.dateModified': Date.now(), - }) - revalidatePath(`/edit/${bid}`) + try { + await firestore().collection('boards').doc(bid).update({ + tiles: tiles, + 'meta.dateModified': Date.now(), + }) + revalidatePath(`/edit/${bid}`) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: + 'Error while saving updated tile ordering to firestore', + boardID: bid, + tilesObjects: tiles, + }, + }) + } } diff --git a/tavla/app/(admin)/edit/[id]/components/DuplicateBoard/actions.ts b/tavla/app/(admin)/edit/[id]/components/DuplicateBoard/actions.ts index 7aefbdd5f..919c70d56 100644 --- a/tavla/app/(admin)/edit/[id]/components/DuplicateBoard/actions.ts +++ b/tavla/app/(admin)/edit/[id]/components/DuplicateBoard/actions.ts @@ -6,6 +6,7 @@ import { getUserFromSessionCookie } from 'app/(admin)/utils/server' import admin, { firestore } from 'firebase-admin' import { redirect } from 'next/navigation' import { TBoard, TOrganization, TOrganizationID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -16,29 +17,37 @@ export async function duplicateBoard(board: TBoard, oid?: TOrganizationID) { let organization: TOrganization | undefined if (oid) organization = await getOrganizationIfUserHasAccess(oid) - const createdBoard = await firestore() - .collection('boards') - .add({ - ...board, - meta: { - ...board.meta, - fontSize: - board.meta?.fontSize ?? - organization?.defaults?.font ?? - 'medium', - created: Date.now(), - dateModified: Date.now(), - }, - }) + let createdBoard = null - firestore() - .collection(oid ? 'organizations' : 'users') - .doc(oid ? String(oid) : String(user.uid)) - .update({ - [oid ? 'boards' : 'owner']: admin.firestore.FieldValue.arrayUnion( - createdBoard.id, - ), - }) + try { + createdBoard = await firestore() + .collection('boards') + .add({ + ...board, + meta: { + ...board.meta, + fontSize: + board.meta?.fontSize ?? + organization?.defaults?.font ?? + 'medium', + created: Date.now(), + dateModified: Date.now(), + }, + }) + if (!createdBoard || !createdBoard.id) + throw Error('failed to create board') + + firestore() + .collection(oid ? 'organizations' : 'users') + .doc(oid ? String(oid) : String(user.uid)) + .update({ + [oid ? 'boards' : 'owner']: + admin.firestore.FieldValue.arrayUnion(createdBoard.id), + }) + } catch (error) { + Sentry.captureMessage('Error while duplicating board object: ' + board) + throw error + } redirect(`/edit/${createdBoard.id}`) } diff --git a/tavla/app/(admin)/edit/[id]/components/Footer/actions.ts b/tavla/app/(admin)/edit/[id]/components/Footer/actions.ts index 18a60c24f..29b74ddf3 100644 --- a/tavla/app/(admin)/edit/[id]/components/Footer/actions.ts +++ b/tavla/app/(admin)/edit/[id]/components/Footer/actions.ts @@ -9,6 +9,7 @@ import { firestore } from 'firebase-admin' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { TBoardID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -31,7 +32,13 @@ export async function setFooter(bid: TBoardID, data: FormData) { 'meta.dateModified': Date.now(), }) revalidatePath(`edit/${bid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while setting footer of board', + boardID: bid, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/edit/[id]/components/MetaSettings/actions.ts b/tavla/app/(admin)/edit/[id]/components/MetaSettings/actions.ts index d0e8ad153..f356bafde 100644 --- a/tavla/app/(admin)/edit/[id]/components/MetaSettings/actions.ts +++ b/tavla/app/(admin)/edit/[id]/components/MetaSettings/actions.ts @@ -16,6 +16,7 @@ import { getUserFromSessionCookie } from 'app/(admin)/utils/server' import { getBoard } from 'Board/scenarios/Board/firebase' import { isEmptyOrSpaces } from 'app/(admin)/edit/utils' import { handleError } from 'app/(admin)/utils/handleError' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -40,8 +41,14 @@ export async function saveTitle( 'meta.dateModified': Date.now(), }) revalidatePath(`/edit/${bid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while saving title of board tile', + boardID: bid, + }, + }) + return handleError(error) } } @@ -57,8 +64,14 @@ export async function saveFont(bid: TBoardID, data: FormData) { .doc(bid) .update({ 'meta.fontSize': font, 'meta.dateModified': Date.now() }) revalidatePath(`/edit/${bid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while updating font size of board', + boardID: bid, + }, + }) + return handleError(error) } } @@ -81,8 +94,15 @@ export async function saveLocation(bid: TBoardID, location?: TLocation) { 'meta.dateModified': Date.now(), }) revalidatePath(`/edit/${bid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while updating location of board', + boardID: bid, + location: location, + }, + }) + return handleError(error) } } @@ -134,7 +154,15 @@ export async function moveBoard( .update({ owner: admin.firestore.FieldValue.arrayUnion(bid) }) revalidatePath(`/edit/${bid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while moving board to new organization', + boardID: bid, + newOrg: oid, + oldOrg: fromOrganization, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/edit/[id]/components/ThemeSelect/actions.ts b/tavla/app/(admin)/edit/[id]/components/ThemeSelect/actions.ts index b3d3aa5ba..052b2afd0 100644 --- a/tavla/app/(admin)/edit/[id]/components/ThemeSelect/actions.ts +++ b/tavla/app/(admin)/edit/[id]/components/ThemeSelect/actions.ts @@ -8,6 +8,7 @@ import { firestore } from 'firebase-admin' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { TBoardID, TTheme } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -25,7 +26,14 @@ export async function setTheme(bid: TBoardID, theme?: TTheme) { }) revalidatePath(`/edit/${bid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while updating theme of board', + boardID: bid, + newTheme: theme, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/edit/[id]/components/TileCard/actions.ts b/tavla/app/(admin)/edit/[id]/components/TileCard/actions.ts index f3b4b703d..07ec3dbef 100644 --- a/tavla/app/(admin)/edit/[id]/components/TileCard/actions.ts +++ b/tavla/app/(admin)/edit/[id]/components/TileCard/actions.ts @@ -9,6 +9,7 @@ import { initializeAdminApp, } from 'app/(admin)/utils/firebase' import { redirect } from 'next/navigation' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -16,55 +17,82 @@ export async function deleteTile(bid: TBoardID, tile: TTile) { const access = await hasBoardOwnerAccess(bid) if (!access) return redirect('/') - const boardRef = firestore().collection('boards').doc(bid) - const board = (await boardRef.get()).data() as TBoard - const tileToDelete = board.tiles.find((t) => t.uuid === tile.uuid) + try { + const boardRef = firestore().collection('boards').doc(bid) + const board = (await boardRef.get()).data() as TBoard + const tileToDelete = board.tiles.find((t) => t.uuid === tile.uuid) - await firestore() - .collection('boards') - .doc(bid) - .update({ - tiles: firestore.FieldValue.arrayRemove(tileToDelete), - 'meta.dateModified': Date.now(), + await firestore() + .collection('boards') + .doc(bid) + .update({ + tiles: firestore.FieldValue.arrayRemove(tileToDelete), + 'meta.dateModified': Date.now(), + }) + revalidatePath(`/edit/${bid}`) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while deleting tile from board', + boardID: bid, + tileObject: tile, + }, }) - revalidatePath(`/edit/${bid}`) + } } export async function saveTile(bid: TBoardID, tile: TTile) { const access = await hasBoardEditorAccess(bid) if (!access) return redirect('/') - const boardRef = firestore().collection('boards').doc(bid) - const board = (await boardRef.get()).data() as TBoard - const existingTile = board.tiles.find((t) => t.uuid === tile.uuid) - if (!existingTile) - return boardRef.update({ - tiles: firestore.FieldValue.arrayUnion(tile), - 'meta.dateModified': Date.now(), - }) - const indexExistingTile = board.tiles.indexOf(existingTile) + try { + const boardRef = firestore().collection('boards').doc(bid) + const board = (await boardRef.get()).data() as TBoard + const existingTile = board.tiles.find((t) => t.uuid === tile.uuid) + if (!existingTile) + return boardRef.update({ + tiles: firestore.FieldValue.arrayUnion(tile), + 'meta.dateModified': Date.now(), + }) + const indexExistingTile = board.tiles.indexOf(existingTile) - if (tile.displayName) { - board.tiles[indexExistingTile] = { - ...tile, - displayName: tile.displayName.substring(0, 50), + if (tile.displayName) { + board.tiles[indexExistingTile] = { + ...tile, + displayName: tile.displayName.substring(0, 50), + } + } else { + board.tiles[indexExistingTile] = tile } - } else { - board.tiles[indexExistingTile] = tile - } - boardRef.update({ tiles: board.tiles, 'meta.dateModified': Date.now() }) + boardRef.update({ tiles: board.tiles, 'meta.dateModified': Date.now() }) - revalidatePath(`/edit/${bid}`) + revalidatePath(`/edit/${bid}`) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while saving tile', + boardID: bid, + tileObject: tile, + }, + }) + } } export async function getOrganizationForBoard(bid: TBoardID) { - const ref = await firestore() - .collection('organizations') - .where('boards', 'array-contains', bid) - .get() + try { + const ref = await firestore() + .collection('organizations') + .where('boards', 'array-contains', bid) + .get() - return ref.docs.map( - (doc) => ({ id: doc.id, ...doc.data() }) as TOrganization, - )[0] + return ref.docs.map( + (doc) => ({ id: doc.id, ...doc.data() }) as TOrganization, + )[0] + } catch (error) { + Sentry.captureMessage( + 'Error while fetching organization for board: ' + bid, + ) + throw error + } } diff --git a/tavla/app/(admin)/edit/[id]/components/TileList/index.tsx b/tavla/app/(admin)/edit/[id]/components/TileList/index.tsx index dfd2bfdf6..f9b96a023 100644 --- a/tavla/app/(admin)/edit/[id]/components/TileList/index.tsx +++ b/tavla/app/(admin)/edit/[id]/components/TileList/index.tsx @@ -4,7 +4,7 @@ import { TBoard, TBoardID } from 'types/settings' import { TileCard } from 'app/(admin)/edit/[id]/components/TileCard/index' import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { TTile } from 'types/tile' -import { saveTiles } from '../../actions' +import { saveUpdatedTileOrder } from '../../actions' import { debounce } from 'lodash' function TileList({ @@ -40,7 +40,7 @@ function TileList({ const newBoard: TBoard = { ...board, tiles: newArray } setDemoBoard(newBoard ?? board) } else { - saveTiles(board.id ?? '', newArray) + saveUpdatedTileOrder(board.id ?? '', newArray) } } const debouncedSave = debounce(moveItem, 150) diff --git a/tavla/app/(admin)/organizations/components/CountiesSelect/actions.ts b/tavla/app/(admin)/organizations/components/CountiesSelect/actions.ts index b76d8f1f4..6371bb294 100644 --- a/tavla/app/(admin)/organizations/components/CountiesSelect/actions.ts +++ b/tavla/app/(admin)/organizations/components/CountiesSelect/actions.ts @@ -9,6 +9,7 @@ import { firestore } from 'firebase-admin' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { TOrganizationID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -26,7 +27,13 @@ export async function setCounties( 'defaults.counties': counties, }) revalidatePath(`/organizations/${oid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while setting counties in organization', + orgID: oid, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/organizations/components/CreateOrganization/actions.ts b/tavla/app/(admin)/organizations/components/CreateOrganization/actions.ts index c3825ea6e..2e2516974 100644 --- a/tavla/app/(admin)/organizations/components/CreateOrganization/actions.ts +++ b/tavla/app/(admin)/organizations/components/CreateOrganization/actions.ts @@ -6,6 +6,8 @@ import { getUserFromSessionCookie } from 'app/(admin)/utils/server' import { firestore } from 'firebase-admin' import { redirect } from 'next/navigation' import { DEFAULT_ORGANIZATION_COLUMNS } from 'types/column' +import * as Sentry from '@sentry/nextjs' +import { handleError } from 'app/(admin)/utils/handleError' initializeAdminApp() @@ -22,18 +24,28 @@ export async function createOrganization( if (!user) return getFormFeedbackForError('auth/operation-not-allowed') - const organization = await firestore() - .collection('organizations') - .add({ - name: name.substring(0, 50), - owners: [user.uid], - editors: [], - boards: [], - defaults: { - columns: DEFAULT_ORGANIZATION_COLUMNS, + let organization = null + + try { + organization = await firestore() + .collection('organizations') + .add({ + name: name.substring(0, 50), + owners: [user.uid], + editors: [], + boards: [], + defaults: { + columns: DEFAULT_ORGANIZATION_COLUMNS, + }, + }) + if (!organization || !organization.id) return getFormFeedbackForError() + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while creating new organization in firestore', }, }) - if (!organization || !organization.id) return getFormFeedbackForError() - + return handleError(error) + } redirect(`/organizations/${organization.id}`) } diff --git a/tavla/app/(admin)/organizations/components/DefaultColumns/actions.ts b/tavla/app/(admin)/organizations/components/DefaultColumns/actions.ts index b9f4b7f92..97b9bd4e4 100644 --- a/tavla/app/(admin)/organizations/components/DefaultColumns/actions.ts +++ b/tavla/app/(admin)/organizations/components/DefaultColumns/actions.ts @@ -10,6 +10,7 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { TColumn } from 'types/column' import { TOrganizationID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -31,7 +32,14 @@ export async function saveColumns( 'defaults.columns': columns, }) revalidatePath(`/organizations/${oid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while saving columns in organization', + orgID: oid, + columnsList: columns, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/organizations/components/FontSelect/actions.ts b/tavla/app/(admin)/organizations/components/FontSelect/actions.ts index c46371f96..7edab63ad 100644 --- a/tavla/app/(admin)/organizations/components/FontSelect/actions.ts +++ b/tavla/app/(admin)/organizations/components/FontSelect/actions.ts @@ -10,6 +10,7 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { TFontSize } from 'types/meta' import { TOrganizationID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -27,7 +28,14 @@ export async function setFontSize( 'defaults.font': fontSize, }) revalidatePath(`/organizations/${oid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while setting font size in organization', + orgID: oid, + fontSizeValue: fontSize, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/organizations/components/Footer/actions.ts b/tavla/app/(admin)/organizations/components/Footer/actions.ts index a86982101..b93fb5d5e 100644 --- a/tavla/app/(admin)/organizations/components/Footer/actions.ts +++ b/tavla/app/(admin)/organizations/components/Footer/actions.ts @@ -7,6 +7,7 @@ import { firestore } from 'firebase-admin' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { TOrganizationID } from 'types/settings' +import * as Sentry from '@sentry/nextjs' export async function setFooter( oid: TOrganizationID | undefined, @@ -28,7 +29,14 @@ export async function setFooter( : firestore.FieldValue.delete(), }) revalidatePath(`organizations/${oid}`) - } catch (e) { - return handleError(e) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while setting organization footer', + orgID: oid, + footerMessage: message, + }, + }) + return handleError(error) } } diff --git a/tavla/app/(admin)/organizations/components/MemberAdministration/actions.ts b/tavla/app/(admin)/organizations/components/MemberAdministration/actions.ts index fb478f1a0..79ea130b0 100644 --- a/tavla/app/(admin)/organizations/components/MemberAdministration/actions.ts +++ b/tavla/app/(admin)/organizations/components/MemberAdministration/actions.ts @@ -5,6 +5,8 @@ import { userCanEditOrganization } from 'app/(admin)/utils/firebase' import admin, { auth, firestore } from 'firebase-admin' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' +import * as Sentry from '@sentry/nextjs' +import { handleError } from 'app/(admin)/utils/handleError' export async function removeUser( prevState: TFormFeedback | undefined, @@ -16,15 +18,26 @@ export async function removeUser( const access = await userCanEditOrganization(organizationId) if (!access) return redirect('/') - await firestore() - .collection('organizations') - .doc(organizationId) - .update({ - owners: admin.firestore.FieldValue.arrayRemove(uid), - editors: admin.firestore.FieldValue.arrayRemove(uid), - }) + try { + await firestore() + .collection('organizations') + .doc(organizationId) + .update({ + owners: admin.firestore.FieldValue.arrayRemove(uid), + editors: admin.firestore.FieldValue.arrayRemove(uid), + }) - revalidatePath('/') + revalidatePath('/') + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while removing user from organization', + orgID: organizationId, + userID: uid, + }, + }) + return handleError(error) + } } export async function inviteUser( @@ -52,12 +65,22 @@ export async function inviteUser( if (organization?.owners?.includes(invitee.uid)) return getFormFeedbackForError('organization/user-already-invited') - await firestore() - .collection('organizations') - .doc(oid) - .update({ - owners: admin.firestore.FieldValue.arrayUnion(invitee.uid), + try { + await firestore() + .collection('organizations') + .doc(oid) + .update({ + owners: admin.firestore.FieldValue.arrayUnion(invitee.uid), + }) + revalidatePath('/') + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while inviting user to organization', + orgID: oid, + inviteeID: invitee.uid, + }, }) - - revalidatePath('/') + return handleError(error) + } } diff --git a/tavla/app/(admin)/organizations/components/UploadLogo/actions.ts b/tavla/app/(admin)/organizations/components/UploadLogo/actions.ts index bd9efa6ca..4c1e1aefc 100644 --- a/tavla/app/(admin)/organizations/components/UploadLogo/actions.ts +++ b/tavla/app/(admin)/organizations/components/UploadLogo/actions.ts @@ -11,6 +11,9 @@ import { userCanEditOrganization, } from 'app/(admin)/utils/firebase' import { redirect } from 'next/navigation' +import { handleError } from 'app/(admin)/utils/handleError' +import * as Sentry from '@sentry/nextjs' + initializeAdminApp() export async function remove(oid?: TOrganizationID, logo?: TLogo) { @@ -24,14 +27,25 @@ export async function remove(oid?: TOrganizationID, logo?: TLogo) { const access = userCanEditOrganization(oid) if (!access) return redirect('/') - const bucket = storage().bucket((await getConfig()).bucket) - const logoFile = bucket.file('logo/' + file) - - await logoFile.delete() - - await firestore().collection('organizations').doc(oid).update({ - logo: firestore.FieldValue.delete(), - }) - - revalidatePath('/') + try { + const bucket = storage().bucket((await getConfig()).bucket) + const logoFile = bucket.file('logo/' + file) + + await logoFile.delete() + + await firestore().collection('organizations').doc(oid).update({ + logo: firestore.FieldValue.delete(), + }) + + revalidatePath('/') + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while removing logo from organization', + orgID: oid, + fileName: file, + }, + }) + return handleError(error) + } } diff --git a/tavla/app/(admin)/utils/firebase.ts b/tavla/app/(admin)/utils/firebase.ts index 9ed1abb7e..ea5755b8c 100644 --- a/tavla/app/(admin)/utils/firebase.ts +++ b/tavla/app/(admin)/utils/firebase.ts @@ -1,12 +1,13 @@ 'use server' import admin, { auth, firestore } from 'firebase-admin' -import { TBoardID, TOrganization, TOrganizationID, TUser } from 'types/settings' +import { TBoardID, TOrganizationID, TUser } from 'types/settings' import { getUserFromSessionCookie } from './server' import { getBoardsForOrganization, getOrganizationIfUserHasAccess, } from '../actions' -import { getOrganizationForBoard } from '../edit/[id]/components/TileCard/actions' +import * as Sentry from '@sentry/nextjs' +import { getOrganizationWithBoard } from 'Board/scenarios/Board/firebase' initializeAdminApp() @@ -75,39 +76,36 @@ export async function hasBoardEditorAccess(bid?: TBoardID) { return userEditorAccess } -export async function getOrganizationWithBoard(bid: TBoardID) { - const ref = await firestore() - .collection('organizations') - .where('boards', 'array-contains', bid) - .get() - return ref.docs.map((doc) => doc.data() as TOrganization)[0] -} - export async function deleteBoard(bid: TBoardID) { const user = await getUserFromSessionCookie() const access = await hasBoardOwnerAccess(bid) if (!user || !access) throw 'auth/operation-not-allowed' - const organization = await getOrganizationForBoard(bid) - - await firestore().collection('boards').doc(bid).delete() - - if (organization?.id) { - await firestore() - .collection('organizations') - .doc(organization.id) - .update({ - boards: admin.firestore.FieldValue.arrayRemove(bid), - }) - } else { - await firestore() - .collection('users') - .doc(user.uid) - .update({ - owner: admin.firestore.FieldValue.arrayRemove(bid), - editor: admin.firestore.FieldValue.arrayRemove(bid), - }) + const organization = await getOrganizationWithBoard(bid) + + try { + await firestore().collection('boards').doc(bid).delete() + + if (organization?.id) { + await firestore() + .collection('organizations') + .doc(organization.id) + .update({ + boards: admin.firestore.FieldValue.arrayRemove(bid), + }) + } else { + await firestore() + .collection('users') + .doc(user.uid) + .update({ + owner: admin.firestore.FieldValue.arrayRemove(bid), + editor: admin.firestore.FieldValue.arrayRemove(bid), + }) + } + } catch (error) { + Sentry.captureMessage('Failed to delete board with id: ' + bid) + throw error } } @@ -145,5 +143,12 @@ export async function deleteOrganizationBoard( ) { const access = await userCanEditOrganization(oid) if (!access) throw 'auth/operation-not-allowed' - return firestore().collection('boards').doc(bid).delete() + try { + return firestore().collection('boards').doc(bid).delete() + } catch (error) { + Sentry.captureMessage( + 'Erorr while deleting board ' + bid + ' in organization ' + oid, + ) + throw error + } } diff --git a/tavla/app/(admin)/utils/handleError.ts b/tavla/app/(admin)/utils/handleError.ts index b3ab953c9..16048dfde 100644 --- a/tavla/app/(admin)/utils/handleError.ts +++ b/tavla/app/(admin)/utils/handleError.ts @@ -1,14 +1,10 @@ -/* eslint-disable no-console */ -// TODO: switch console.log to Sentry.captureException import { FirebaseError } from 'firebase/app' import { getFormFeedbackForError } from '.' +import { isString } from 'lodash' export function handleError(e: unknown) { - if (e instanceof FirebaseError) { - console.error(e.message) + if (e instanceof FirebaseError || isString(e)) { return getFormFeedbackForError(e) - } else if (e instanceof Error) { - console.error(e.message) } return getFormFeedbackForError('general') } diff --git a/tavla/app/(admin)/utils/types.ts b/tavla/app/(admin)/utils/types.ts index 1b6e896d4..e4468b39d 100644 --- a/tavla/app/(admin)/utils/types.ts +++ b/tavla/app/(admin)/utils/types.ts @@ -15,19 +15,3 @@ export const DEFAULT_BOARD_COLUMNS = Object.keys( export type TBoardsColumn = keyof typeof BoardsColumns export const SortableColumns = ['name', 'organization', 'lastModified'] as const - -export type TTavlaError = - | 'AUTHORIZATION' - | 'BOARD' - | 'NOT_FOUND' - | 'ORGANIZATION' - -export class TavlaError extends Error { - code: TTavlaError - message: string - constructor({ code, message }: { code: TTavlaError; message: string }) { - super() - this.code = code - this.message = message - } -} diff --git a/tavla/app/components/actions.ts b/tavla/app/components/actions.ts index 62fbc2c55..c41b56beb 100644 --- a/tavla/app/components/actions.ts +++ b/tavla/app/components/actions.ts @@ -3,6 +3,8 @@ import { isEmptyOrSpaces } from 'app/(admin)/edit/utils' import { TFormFeedback, getFormFeedbackForError } from 'app/(admin)/utils' import { validEmail } from 'utils/email' +import * as Sentry from '@sentry/nextjs' +import { handleError } from 'app/(admin)/utils/handleError' async function postForm(prevState: TFormFeedback | undefined, data: FormData) { const email = data.get('email') as string @@ -106,8 +108,14 @@ async function postForm(prevState: TFormFeedback | undefined, data: FormData) { if (!response.ok) { throw Error('Error in request') } - } catch { - return getFormFeedbackForError('general') + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while submitting contact form', + formMessage: message, + }, + }) + return handleError(error) } } diff --git a/tavla/src/Board/scenarios/Board/firebase.ts b/tavla/src/Board/scenarios/Board/firebase.ts index 6d90deff8..7c5ae820d 100644 --- a/tavla/src/Board/scenarios/Board/firebase.ts +++ b/tavla/src/Board/scenarios/Board/firebase.ts @@ -1,6 +1,7 @@ import { makeBoardCompatible } from 'app/(admin)/edit/[id]/compatibility' import admin, { firestore } from 'firebase-admin' import { TBoard, TBoardID, TOrganization } from 'types/settings' +import * as Sentry from '@sentry/nextjs' initializeAdminApp() @@ -14,42 +15,87 @@ async function initializeAdminApp() { } export async function getBoard(bid: TBoardID) { - const board = await firestore().collection('boards').doc(bid).get() - if (!board.exists) { - return undefined + try { + const board = await firestore().collection('boards').doc(bid).get() + if (!board.exists) { + return undefined + } + return makeBoardCompatible({ id: board.id, ...board.data() } as TBoard) + } catch (error) { + Sentry.captureMessage('Failed to fetch board with bid ' + bid) + throw error } - return makeBoardCompatible({ id: board.id, ...board.data() } as TBoard) } export async function getOrganizationWithBoard(bid: TBoardID) { - const ref = await firestore() - .collection('organizations') - .where('boards', 'array-contains', bid) - .get() - return ref.docs.map((doc) => doc.data() as TOrganization)[0] ?? null + try { + const ref = await firestore() + .collection('organizations') + .where('boards', 'array-contains', bid) + .get() + return ref.docs.map((doc) => doc.data() as TOrganization)[0] ?? null + } catch (error) { + Sentry.captureMessage('Failed to fetch organization with board ' + bid) + throw error + } } export async function getOrganizationLogoWithBoard(bid: TBoardID) { - const ref = await firestore() - .collection('organizations') - .where('boards', 'array-contains', bid) - .get() - const organization = ref.docs.map((doc) => doc.data() as TOrganization)[0] - return organization?.logo ?? null + try { + const ref = await firestore() + .collection('organizations') + .where('boards', 'array-contains', bid) + .get() + const organization = ref.docs.map( + (doc) => doc.data() as TOrganization, + )[0] + return organization?.logo ?? null + } catch (error) { + Sentry.captureException(error, { + extra: { + message: 'Error while fetching logo of organization of board', + boardID: bid, + }, + }) + return null + } } export async function getOrganizationFooterWithBoard(bid: TBoardID) { - const ref = await firestore() - .collection('organizations') - .where('boards', 'array-contains', bid) - .get() - const organization = ref.docs.map((doc) => doc.data() as TOrganization)[0] - return organization?.footer ?? null + try { + const ref = await firestore() + .collection('organizations') + .where('boards', 'array-contains', bid) + .get() + const organization = ref.docs.map( + (doc) => doc.data() as TOrganization, + )[0] + return organization?.footer ?? null + } catch (error) { + Sentry.captureException(error, { + extra: { + message: + 'Error while fetching organization footer for board from firestore', + boardID: bid, + }, + }) + return null + } } export async function ping(bid: TBoardID) { - await firestore() - .collection('boards') - .doc(bid) - .update({ 'meta.lastActive': Date.now() }) + try { + await firestore() + .collection('boards') + .doc(bid) + .update({ 'meta.lastActive': Date.now() }) + } catch (error) { + Sentry.captureException(error, { + extra: { + message: + 'Error while updating lastActive-field of board (ping)', + boardID: bid, + }, + }) + } }