From 5218a24bd1cf52c2cc3d15e75de00c8a1ec2c196 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Wed, 15 Apr 2026 17:29:44 -0400 Subject: [PATCH] Changed resuce to status to allow cancellation, enabled email notifications --- backend/src/app.ts | 39 ++++++++- .../src/repositories/workspaceRepository.ts | 30 ++++++- frontend/src/App.tsx | 79 ++++++++++++++++--- 3 files changed, 136 insertions(+), 12 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index c7cbb00..787316b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -41,6 +41,7 @@ import { } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { + cancelRescueVerificationRequest, claimWorkspaceInvites, createWorkspace, deleteWorkspaceMember, @@ -503,10 +504,17 @@ const sendRescueStatusNotification = async ({ }: { workspace: WorkspaceRow; ownerEmail: string | null; - event: 'created' | 'converted' | 'status_changed'; + event: 'created' | 'converted' | 'status_changed' | 'canceled'; }) => { const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' '); - const eventLabel = event === 'created' ? 'created' : event === 'converted' ? 'converted to rescue' : 'status updated'; + const eventLabel = + event === 'created' + ? 'created' + : event === 'converted' + ? 'converted to rescue' + : event === 'canceled' + ? 'canceled rescue request' + : 'status updated'; const subject = `FlockPal rescue status: ${workspace.name} ${eventLabel}`; const escapedWorkspaceName = escapeHtml(workspace.name); const escapedStatusLabel = escapeHtml(statusLabel); @@ -1213,6 +1221,33 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole( } }); +app.post( + '/api/workspace/rescue-status/cancel', + requireAuth, + requireSessionAuth, + requireWorkspaceRole(['owner', 'assistant']), + async (req: Request, res: Response, next: NextFunction) => { + try { + const workspace = await cancelRescueVerificationRequest(req.auth!.workspace.id); + + if (!workspace) { + res.status(409).json({ error: 'Only pending rescue status requests can be canceled.' }); + return; + } + + await sendRescueStatusNotification({ + workspace, + ownerEmail: req.auth!.user.email, + event: 'canceled', + }); + + res.json({ workspace: normalizeWorkspace(workspace) }); + } catch (error) { + next(error); + } + }, +); + app.get('/api/workspace/members', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const members = await listWorkspaceMembers(req.auth!.workspace.id); diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index af58d8e..64d7438 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -314,7 +314,18 @@ export const listRescueWorkspacesForAdmin = async () => { export const updateRescueVerificationStatus = async (workspaceId: number, status: RescueVerificationStatus) => { const result = await db.query( `UPDATE workspaces - SET rescue_verification_status = $2, + SET workspace_type = CASE + WHEN $2 = 'rejected' THEN 'standard' + ELSE workspace_type + END, + billing_plan = CASE + WHEN $2 = 'rejected' THEN 'household_basic' + ELSE billing_plan + END, + rescue_verification_status = CASE + WHEN $2 = 'rejected' THEN 'not_required' + ELSE $2 + END, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND workspace_type = 'rescue' @@ -325,6 +336,23 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status return result.rows[0] ?? null; }; +export const cancelRescueVerificationRequest = async (workspaceId: number) => { + const result = await db.query( + `UPDATE workspaces + SET workspace_type = 'standard', + billing_plan = 'household_basic', + rescue_verification_status = 'not_required', + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + AND workspace_type = 'rescue' + AND rescue_verification_status = 'pending' + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`, + [workspaceId], + ); + + return result.rows[0] ?? null; +}; + export const getPlatformAdminSummary = async () => { const result = await db.query<{ total_birds: number; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1dbaf15..3777fb7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -834,6 +834,7 @@ function App() { const [applyingPhotoCrop, setApplyingPhotoCrop] = useState(false); const [savingBird, setSavingBird] = useState(false); const [savingWorkspace, setSavingWorkspace] = useState(false); + const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); @@ -1576,18 +1577,17 @@ function App() { throw new Error('Unable to update rescue verification status.'); } - setAdminRescueWorkspaces((current) => - current.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)), - ); + const nextRescueWorkspaces = adminRescueWorkspaces + .map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)) + .filter((entry) => entry.workspace.workspaceType === 'rescue'); + + setAdminRescueWorkspaces(nextRescueWorkspaces); setAdminSummary((current) => current ? { ...current, - pendingRescues: adminRescueWorkspaces.filter((entry) => - entry.workspace.id === workspaceId - ? rescueVerificationStatus === 'pending' - : entry.workspace.rescueVerificationStatus === 'pending', - ).length, + rescueWorkspaces: nextRescueWorkspaces.length, + pendingRescues: nextRescueWorkspaces.filter((entry) => entry.workspace.rescueVerificationStatus === 'pending').length, } : current, ); @@ -2213,6 +2213,56 @@ function App() { } }; + const handleCancelRescueRequest = async () => { + if (!authToken) { + return; + } + + setError(''); + setCancelingRescueRequest(true); + + try { + const response = await apiFetch('/workspace/rescue-status/cancel', authToken, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to cancel rescue status request.')); + } + + const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; + + if (!data.workspace) { + throw new Error('Unable to cancel rescue status request.'); + } + + const savedWorkspace = data.workspace; + + setWorkspace(savedWorkspace); + setAuthSession((current) => + current + ? { + ...current, + activeWorkspace: savedWorkspace, + workspaces: current.workspaces.map((entry) => + entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry, + ), + } + : current, + ); + setWorkspaceForm({ + name: savedWorkspace.name, + workspaceType: savedWorkspace.workspaceType, + billingEmail: savedWorkspace.billingEmail ?? '', + billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', + }); + } catch (workspaceError) { + setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.'); + } finally { + setCancelingRescueRequest(false); + } + }; + const handleWorkspaceMemberSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); @@ -2660,7 +2710,7 @@ function App() { type="button" disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'} > - Reject + Reject and make household @@ -3195,6 +3245,17 @@ function App() {
{formatRescueVerificationStatus(workspace.rescueVerificationStatus)} Rescue flocks are read-only until an admin approves their verification. + {workspace.rescueVerificationStatus === 'pending' && + (activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? ( + + ) : null}
) : null}