Changed resuce to status to allow cancellation, enabled email notifications
This commit is contained in:
+37
-2
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user