Changed resuce to status to allow cancellation, enabled email notifications

This commit is contained in:
Corey Blais
2026-04-15 17:29:44 -04:00
parent ac0cc122d3
commit 5218a24bd1
3 changed files with 136 additions and 12 deletions
+37 -2
View File
@@ -41,6 +41,7 @@ import {
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
import { import {
cancelRescueVerificationRequest,
claimWorkspaceInvites, claimWorkspaceInvites,
createWorkspace, createWorkspace,
deleteWorkspaceMember, deleteWorkspaceMember,
@@ -503,10 +504,17 @@ const sendRescueStatusNotification = async ({
}: { }: {
workspace: WorkspaceRow; workspace: WorkspaceRow;
ownerEmail: string | null; ownerEmail: string | null;
event: 'created' | 'converted' | 'status_changed'; event: 'created' | 'converted' | 'status_changed' | 'canceled';
}) => { }) => {
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' '); 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 subject = `FlockPal rescue status: ${workspace.name} ${eventLabel}`;
const escapedWorkspaceName = escapeHtml(workspace.name); const escapedWorkspaceName = escapeHtml(workspace.name);
const escapedStatusLabel = escapeHtml(statusLabel); 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) => { app.get('/api/workspace/members', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const members = await listWorkspaceMembers(req.auth!.workspace.id); const members = await listWorkspaceMembers(req.auth!.workspace.id);
@@ -314,7 +314,18 @@ export const listRescueWorkspacesForAdmin = async () => {
export const updateRescueVerificationStatus = async (workspaceId: number, status: RescueVerificationStatus) => { export const updateRescueVerificationStatus = async (workspaceId: number, status: RescueVerificationStatus) => {
const result = await db.query<WorkspaceRow>( const result = await db.query<WorkspaceRow>(
`UPDATE workspaces `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 updated_at = CURRENT_TIMESTAMP
WHERE id = $1 WHERE id = $1
AND workspace_type = 'rescue' AND workspace_type = 'rescue'
@@ -325,6 +336,23 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const cancelRescueVerificationRequest = async (workspaceId: number) => {
const result = await db.query<WorkspaceRow>(
`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 () => { export const getPlatformAdminSummary = async () => {
const result = await db.query<{ const result = await db.query<{
total_birds: number; total_birds: number;
+70 -9
View File
@@ -834,6 +834,7 @@ function App() {
const [applyingPhotoCrop, setApplyingPhotoCrop] = useState(false); const [applyingPhotoCrop, setApplyingPhotoCrop] = useState(false);
const [savingBird, setSavingBird] = useState(false); const [savingBird, setSavingBird] = useState(false);
const [savingWorkspace, setSavingWorkspace] = useState(false); const [savingWorkspace, setSavingWorkspace] = useState(false);
const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false);
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false);
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
@@ -1576,18 +1577,17 @@ function App() {
throw new Error('Unable to update rescue verification status.'); throw new Error('Unable to update rescue verification status.');
} }
setAdminRescueWorkspaces((current) => const nextRescueWorkspaces = adminRescueWorkspaces
current.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)), .map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry))
); .filter((entry) => entry.workspace.workspaceType === 'rescue');
setAdminRescueWorkspaces(nextRescueWorkspaces);
setAdminSummary((current) => setAdminSummary((current) =>
current current
? { ? {
...current, ...current,
pendingRescues: adminRescueWorkspaces.filter((entry) => rescueWorkspaces: nextRescueWorkspaces.length,
entry.workspace.id === workspaceId pendingRescues: nextRescueWorkspaces.filter((entry) => entry.workspace.rescueVerificationStatus === 'pending').length,
? rescueVerificationStatus === 'pending'
: entry.workspace.rescueVerificationStatus === 'pending',
).length,
} }
: current, : 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<HTMLFormElement>) => { const handleWorkspaceMemberSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
@@ -2660,7 +2710,7 @@ function App() {
type="button" type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'} disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'}
> >
Reject Reject and make household
</button> </button>
</div> </div>
</article> </article>
@@ -3195,6 +3245,17 @@ function App() {
<article className="summary-card"> <article className="summary-card">
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong> <strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
<span>Rescue flocks are read-only until an admin approves their verification.</span> <span>Rescue flocks are read-only until an admin approves their verification.</span>
{workspace.rescueVerificationStatus === 'pending' &&
(activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? (
<button
className="secondary-button"
type="button"
onClick={handleCancelRescueRequest}
disabled={cancelingRescueRequest}
>
{cancelingRescueRequest ? 'Canceling request...' : 'Cancel rescue request'}
</button>
) : null}
</article> </article>
) : null} ) : null}
<article className="summary-card"> <article className="summary-card">