fixing rescue settings
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
|||||||
listBirds,
|
listBirds,
|
||||||
listVetVisitsForBird,
|
listVetVisitsForBird,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
|
transferBirdToWorkspace,
|
||||||
updateBird,
|
updateBird,
|
||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
@@ -45,7 +46,10 @@ import {
|
|||||||
claimWorkspaceInvites,
|
claimWorkspaceInvites,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspaceMember,
|
deleteWorkspaceMember,
|
||||||
|
deleteWorkspaceIfEmpty,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
|
findAlternateWorkspaceForUser,
|
||||||
|
getWorkspaceBirdCount,
|
||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
getMembershipForUser,
|
getMembershipForUser,
|
||||||
getNextWorkspaceId,
|
getNextWorkspaceId,
|
||||||
@@ -158,6 +162,10 @@ const transferDraftSchema = z.object({
|
|||||||
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flockTransferSchema = z.object({
|
||||||
|
targetWorkspaceId: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
const birdSchema = z.object({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().min(1).max(80),
|
tagId: z.string().trim().min(1).max(80),
|
||||||
@@ -1226,6 +1234,55 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
if ((await getWorkspaceBirdCount(req.auth!.workspace.id)) > 0) {
|
||||||
|
res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!nextWorkspaceId) {
|
||||||
|
const fallbackWorkspaceId = await getNextWorkspaceId();
|
||||||
|
const fallbackWorkspace = await createWorkspace({
|
||||||
|
id: fallbackWorkspaceId,
|
||||||
|
name: `${req.auth!.user.name}'s Flock`,
|
||||||
|
workspaceType: 'standard',
|
||||||
|
billingEmail: req.auth!.user.email,
|
||||||
|
billingPlan: 'household_basic',
|
||||||
|
owner: req.auth!.user,
|
||||||
|
});
|
||||||
|
nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSessionWorkspace(req.auth!.session.id, nextWorkspaceId);
|
||||||
|
|
||||||
|
const deletion = await deleteWorkspaceIfEmpty(req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!deletion.deleted) {
|
||||||
|
await updateSessionWorkspace(req.auth!.session.id, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
res.status(404).json({ error: 'Flock not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token);
|
||||||
|
|
||||||
|
if (!updatedAuth) {
|
||||||
|
throw new Error('Unable to reload session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
deletedWorkspaceId: req.auth!.workspace.id,
|
||||||
|
token: req.auth!.token,
|
||||||
|
session: await buildSessionPayload(updatedAuth),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/api/workspace/rescue-status/cancel',
|
'/api/workspace/rescue-status/cancel',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
@@ -1345,6 +1402,45 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = flockTransferSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid flock transfer payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.targetWorkspaceId === req.auth!.workspace.id) {
|
||||||
|
res.status(400).json({ error: 'Choose a different destination flock.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetMembership = await getMembershipForUser(req.auth!.user.id, parsed.data.targetWorkspaceId);
|
||||||
|
|
||||||
|
if (!targetMembership) {
|
||||||
|
res.status(403).json({ error: 'You do not have access to that destination flock.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, parsed.data.targetWorkspaceId);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ bird: normalizeBird(bird) });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
|
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = birdSchema.safeParse(req.body);
|
const parsed = birdSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createBird, getBirdById, listWeightsForBird } from './birdRepository.js';
|
import { createBird, getBirdById, listWeightsForBird, transferBirdToWorkspace } from './birdRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
|
|
||||||
test('getBirdById returns null when the bird does not exist in the workspace', async () => {
|
test('getBirdById returns null when the bird does not exist in the workspace', async () => {
|
||||||
@@ -70,3 +70,34 @@ test('listWeightsForBird scopes by bird, workspace, and day window', async () =>
|
|||||||
assert.deepEqual(calls[0].params, ['bird-1', 30, 10]);
|
assert.deepEqual(calls[0].params, ['bird-1', 30, 10]);
|
||||||
assert.match(calls[0].text, /FROM weight_records/);
|
assert.match(calls[0].text, /FROM weight_records/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('transferBirdToWorkspace moves the bird to the target workspace', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'bird-1',
|
||||||
|
workspace_id: 22,
|
||||||
|
name: 'Kiwi',
|
||||||
|
tag_id: 'A-1',
|
||||||
|
species: 'Cockatiel',
|
||||||
|
gender: 'female',
|
||||||
|
date_of_birth: null,
|
||||||
|
gotcha_day: null,
|
||||||
|
chart_color: '#cb3a35',
|
||||||
|
photo_data_url: null,
|
||||||
|
notify_on_dob: false,
|
||||||
|
notify_on_gotcha_day: false,
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
latest_weight_grams: '92',
|
||||||
|
latest_recorded_on: '2026-04-14',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const bird = await transferBirdToWorkspace('bird-1', 10, 22);
|
||||||
|
|
||||||
|
assert.equal(bird?.workspace_id, 22);
|
||||||
|
assert.deepEqual(calls[0].params, ['bird-1', 10, 22]);
|
||||||
|
assert.match(calls[0].text, /UPDATE birds/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -168,6 +168,33 @@ export const deleteBird = async (birdId: string, workspaceId: number) => {
|
|||||||
return Boolean(result.rowCount);
|
return Boolean(result.rowCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: number, targetWorkspaceId: number) => {
|
||||||
|
const result = await db.query<BirdRow>(
|
||||||
|
`UPDATE birds
|
||||||
|
SET workspace_id = $3
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at,
|
||||||
|
(
|
||||||
|
SELECT weight_grams::text
|
||||||
|
FROM weight_records
|
||||||
|
WHERE bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS latest_weight_grams,
|
||||||
|
(
|
||||||
|
SELECT recorded_on::text
|
||||||
|
FROM weight_records
|
||||||
|
WHERE bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS latest_recorded_on`,
|
||||||
|
[birdId, sourceWorkspaceId, targetWorkspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
|
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
|
||||||
const result = await db.query<WeightRow>(
|
const result = await db.query<WeightRow>(
|
||||||
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createWorkspace, ensurePersonalWorkspaceForUser, updateWorkspace } from './workspaceRepository.js';
|
import { createWorkspace, deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, updateWorkspace } from './workspaceRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
import type { UserRow } from '../types.js';
|
import type { UserRow } from '../types.js';
|
||||||
|
|
||||||
@@ -95,3 +95,49 @@ test('updateWorkspace converts an existing household flock to rescue without ins
|
|||||||
assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/);
|
assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/);
|
||||||
assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free']);
|
assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceIfEmpty blocks deletion when birds are still assigned', async () => {
|
||||||
|
const { calls } = mockDb(
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ count: '2' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await deleteWorkspaceIfEmpty(42);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { deleted: false, reason: 'birds_present' });
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.match(calls[0].text, /FROM birds/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceIfEmpty deletes an empty workspace', async () => {
|
||||||
|
const { calls } = mockDb(
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ count: '0' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 42 }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await deleteWorkspaceIfEmpty(42);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { deleted: true });
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.match(calls[1].text, /DELETE FROM workspaces/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findAlternateWorkspaceForUser returns another workspace when available', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ workspace_id: 84 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceId = await findAlternateWorkspaceForUser('user-1', 42);
|
||||||
|
|
||||||
|
assert.equal(workspaceId, 84);
|
||||||
|
assert.deepEqual(calls[0].params, ['user-1', 42]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -236,6 +236,50 @@ export const listWorkspaceMembers = async (workspaceId: number) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const findAlternateWorkspaceForUser = async (userId: string, excludeWorkspaceId: number) => {
|
||||||
|
const result = await db.query<{ workspace_id: number }>(
|
||||||
|
`SELECT workspace_id
|
||||||
|
FROM workspace_members
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND workspace_id <> $2
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId, excludeWorkspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ? Number(result.rows[0].workspace_id) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkspaceBirdCount = async (workspaceId: number) => {
|
||||||
|
const birdCount = await db.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count
|
||||||
|
FROM birds
|
||||||
|
WHERE workspace_id = $1`,
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(birdCount.rows[0]?.count ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWorkspaceIfEmpty = async (workspaceId: number) => {
|
||||||
|
if ((await getWorkspaceBirdCount(workspaceId)) > 0) {
|
||||||
|
return { deleted: false as const, reason: 'birds_present' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db.query<{ id: number }>(
|
||||||
|
`DELETE FROM workspaces
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted.rowCount) {
|
||||||
|
return { deleted: false as const, reason: 'not_found' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: true as const };
|
||||||
|
};
|
||||||
|
|
||||||
export const upsertWorkspaceMember = async ({
|
export const upsertWorkspaceMember = async ({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
inviteEmail,
|
inviteEmail,
|
||||||
|
|||||||
@@ -634,6 +634,31 @@ Response `200`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `DELETE /api/workspace`
|
||||||
|
|
||||||
|
Requires a browser session and role `owner`. Deletes the active flock only when it has no birds.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- if the flock still has birds, deletion is blocked
|
||||||
|
- collaborators, sessions, and integration tokens tied to the flock are removed with it
|
||||||
|
- the backend switches the user to another existing flock, or creates a new personal flock automatically if needed
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deletedWorkspaceId": 1001,
|
||||||
|
"token": "raw-session-token",
|
||||||
|
"session": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Possible errors:
|
||||||
|
|
||||||
|
- `409` if birds are still assigned to the flock
|
||||||
|
- `404` if the flock no longer exists
|
||||||
|
|
||||||
#### `GET /api/workspace/members`
|
#### `GET /api/workspace/members`
|
||||||
|
|
||||||
Requires auth. Lists members for the active workspace. Browser sessions and integration tokens can both use this endpoint.
|
Requires auth. Lists members for the active workspace. Browser sessions and integration tokens can both use this endpoint.
|
||||||
@@ -749,6 +774,39 @@ Possible errors:
|
|||||||
- `404` if the bird does not exist in the active workspace
|
- `404` if the bird does not exist in the active workspace
|
||||||
- `409` if the workspace already uses that `tagId`
|
- `409` if the workspace already uses that `tagId`
|
||||||
|
|
||||||
|
#### `POST /api/birds/:birdId/transfer`
|
||||||
|
|
||||||
|
Requires a browser session, write access, and role `owner` or `assistant`. Moves a bird from the active flock to another flock the same user can access.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targetWorkspaceId": 1002
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- the destination flock must be different from the current flock
|
||||||
|
- the signed-in user must already be a member of the destination flock
|
||||||
|
- the bird keeps its existing weight and vet history because the record itself is reassigned
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bird": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Possible errors:
|
||||||
|
|
||||||
|
- `400` if the destination flock is the current flock or the payload is invalid
|
||||||
|
- `403` if the user does not have access to the destination flock
|
||||||
|
- `404` if the bird is not in the active flock
|
||||||
|
- `409` if the destination flock already has a bird using the same `tagId`
|
||||||
|
|
||||||
#### `DELETE /api/birds/:birdId`
|
#### `DELETE /api/birds/:birdId`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
||||||
|
|||||||
+162
-5
@@ -548,7 +548,7 @@ const formatSubscriptionStatus = (status: SubscriptionStatus) => {
|
|||||||
|
|
||||||
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
|
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
|
||||||
if (status === 'approved') {
|
if (status === 'approved') {
|
||||||
return 'Approved';
|
return 'Active';
|
||||||
}
|
}
|
||||||
if (status === 'rejected') {
|
if (status === 'rejected') {
|
||||||
return 'Rejected';
|
return 'Rejected';
|
||||||
@@ -837,6 +837,7 @@ function App() {
|
|||||||
const [cancelingRescueRequest, setCancelingRescueRequest] = 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 [deletingWorkspace, setDeletingWorkspace] = useState(false);
|
||||||
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
|
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
|
||||||
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||||
@@ -864,6 +865,10 @@ function App() {
|
|||||||
destinationOwnerEmail: '',
|
destinationOwnerEmail: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
const [flockTransferForm, setFlockTransferForm] = useState({
|
||||||
|
birdId: '',
|
||||||
|
targetWorkspaceId: '',
|
||||||
|
});
|
||||||
const [deletingBird, setDeletingBird] = useState(false);
|
const [deletingBird, setDeletingBird] = useState(false);
|
||||||
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||||
@@ -874,6 +879,10 @@ function App() {
|
|||||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
||||||
[birds, selectedBirdId],
|
[birds, selectedBirdId],
|
||||||
);
|
);
|
||||||
|
const transferableWorkspaces = useMemo(
|
||||||
|
() => authSession?.workspaces.filter((entry) => entry.workspace.id !== workspace?.id) ?? [],
|
||||||
|
[authSession, workspace?.id],
|
||||||
|
);
|
||||||
const editingBird = useMemo(
|
const editingBird = useMemo(
|
||||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||||
[birds, editingBirdId],
|
[birds, editingBirdId],
|
||||||
@@ -2161,6 +2170,53 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetWorkspaceId: Number(flockTransferForm.targetWorkspaceId),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ bird?: Bird }>(response)) ?? {};
|
||||||
|
const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird';
|
||||||
|
|
||||||
|
setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId));
|
||||||
|
setAllBirdWeights((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[flockTransferForm.birdId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
||||||
|
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
||||||
|
if (selectedBird?.id === flockTransferForm.birdId) {
|
||||||
|
setSelectedBirdId('');
|
||||||
|
}
|
||||||
|
if (editingBirdId === flockTransferForm.birdId) {
|
||||||
|
setEditingBirdId('');
|
||||||
|
setBirdForm(emptyBirdForm);
|
||||||
|
setBirdPhotoName('');
|
||||||
|
}
|
||||||
|
setFlockTransferForm({
|
||||||
|
birdId: '',
|
||||||
|
targetWorkspaceId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
window.alert(`${transferredBirdName} was moved to the selected flock.`);
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -2213,6 +2269,47 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteWorkspace = async () => {
|
||||||
|
if (!workspace || !authToken || deletingWorkspace || activeMembership?.role !== 'owner') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setDeletingWorkspace(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/workspace', authToken, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to delete flock.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
||||||
|
|
||||||
|
if (!data.session) {
|
||||||
|
throw new Error('Flock was deleted but the session could not be refreshed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextToken = data.token || authToken;
|
||||||
|
persistSessionToken(nextToken);
|
||||||
|
applySession(data.session, nextToken);
|
||||||
|
} catch (workspaceError) {
|
||||||
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to delete flock.');
|
||||||
|
} finally {
|
||||||
|
setDeletingWorkspace(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancelRescueRequest = async () => {
|
const handleCancelRescueRequest = async () => {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return;
|
return;
|
||||||
@@ -3226,6 +3323,14 @@ function App() {
|
|||||||
? 'Convert current flock to rescue'
|
? 'Convert current flock to rescue'
|
||||||
: 'Save flock settings'}
|
: 'Save flock settings'}
|
||||||
</button>
|
</button>
|
||||||
|
{activeMembership?.role === 'owner' ? (
|
||||||
|
<button className="danger-button" onClick={handleDeleteWorkspace} type="button" disabled={deletingWorkspace}>
|
||||||
|
{deletingWorkspace ? 'Deleting flock...' : 'Delete flock'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{activeMembership?.role === 'owner' ? (
|
||||||
|
<small className="muted">Delete is only available when this flock has no birds. Collaborators and tokens are removed with it.</small>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -3241,14 +3346,22 @@ function App() {
|
|||||||
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
|
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
|
||||||
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
|
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
|
||||||
</article>
|
</article>
|
||||||
|
{workspace?.workspaceType !== 'rescue' ? (
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
|
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
|
||||||
<span>Flock write access will follow subscription health once billing is connected.</span>
|
<span>Flock write access will follow subscription health once billing is connected.</span>
|
||||||
</article>
|
</article>
|
||||||
|
) : null}
|
||||||
{workspace?.workspaceType === 'rescue' ? (
|
{workspace?.workspaceType === 'rescue' ? (
|
||||||
<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>
|
||||||
|
{workspace.rescueVerificationStatus === 'approved'
|
||||||
|
? 'Rescue verification is approved and this flock is fully active.'
|
||||||
|
: workspace.rescueVerificationStatus === 'rejected'
|
||||||
|
? 'This rescue request was rejected. Update the flock or contact support before trying again.'
|
||||||
|
: 'Rescue flocks are read-only until an admin approves their verification.'}
|
||||||
|
</span>
|
||||||
{workspace.rescueVerificationStatus === 'pending' &&
|
{workspace.rescueVerificationStatus === 'pending' &&
|
||||||
(activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? (
|
(activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? (
|
||||||
<button
|
<button
|
||||||
@@ -3858,12 +3971,56 @@ function App() {
|
|||||||
{expandedSettingsSection === 'transfer' ? (
|
{expandedSettingsSection === 'transfer' ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would
|
Move a bird to another flock you already belong to. This keeps the bird record, weight history, and vet visits attached while
|
||||||
later use to safely move a bird record between accounts.
|
changing which flock owns it.
|
||||||
|
</p>
|
||||||
|
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
|
||||||
|
<label>
|
||||||
|
Bird to move
|
||||||
|
<select
|
||||||
|
value={flockTransferForm.birdId}
|
||||||
|
onChange={(event) => setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a bird from this flock</option>
|
||||||
|
{birds.map((bird) => (
|
||||||
|
<option key={bird.id} value={bird.id}>
|
||||||
|
{bird.name} • {bird.species} • Band {bird.tagId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Destination flock
|
||||||
|
<select
|
||||||
|
value={flockTransferForm.targetWorkspaceId}
|
||||||
|
onChange={(event) => setFlockTransferForm({ ...flockTransferForm, targetWorkspaceId: event.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select another flock you can access</option>
|
||||||
|
{transferableWorkspaces.map((entry) => (
|
||||||
|
<option key={entry.workspace.id} value={String(entry.workspace.id)}>
|
||||||
|
{entry.workspace.name} • {formatWorkspaceRole(entry.membership.role)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit" disabled={!transferableWorkspaces.length}>
|
||||||
|
Move bird to another flock
|
||||||
|
</button>
|
||||||
|
{!transferableWorkspaces.length ? (
|
||||||
|
<small className="muted">Create or join another flock first to use in-app bird transfers.</small>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="form-divider" />
|
||||||
|
|
||||||
|
<p className="muted">
|
||||||
|
For future owner-to-owner handoffs outside your own flocks, save a transfer draft below.
|
||||||
</p>
|
</p>
|
||||||
<form className="form-panel" onSubmit={handleMergeSubmit}>
|
<form className="form-panel" onSubmit={handleMergeSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Bird to transfer
|
Bird for external transfer prep
|
||||||
<select
|
<select
|
||||||
value={mergeForm.birdId}
|
value={mergeForm.birdId}
|
||||||
onChange={(event) => setMergeForm({ ...mergeForm, birdId: event.target.value })}
|
onChange={(event) => setMergeForm({ ...mergeForm, birdId: event.target.value })}
|
||||||
|
|||||||
@@ -815,6 +815,12 @@ textarea {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-divider {
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(53, 129, 98, 0.32), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.inline-form {
|
.inline-form {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user