fixed transfer process

This commit is contained in:
blaisadmin
2026-04-15 23:39:10 -04:00
parent 765d6c61db
commit 3a0e30085c
9 changed files with 480 additions and 231 deletions
+112 -65
View File
@@ -27,7 +27,9 @@ import {
updateUserName,
} from './repositories/authRepository.js';
import {
completePendingBirdTransfersForOwner,
createBird,
createPendingBirdTransfer,
createVetVisitForBird,
createWeightForBird,
deleteBird,
@@ -54,6 +56,7 @@ import {
getMembershipForUser,
getNextWorkspaceId,
getWorkspaceById,
listOwnedWorkspacesByOwnerEmail,
listRescueWorkspacesForAdmin,
listMembershipsForUser,
listWorkspaceMembers,
@@ -156,14 +159,8 @@ const workspaceMemberSchema = z.object({
role: workspaceRoleSchema,
});
const transferDraftSchema = z.object({
birdId: z.string().uuid(),
destinationOwnerEmail: z.string().trim().email().max(255),
notes: z.string().trim().max(1000).optional().or(z.literal('')),
});
const flockTransferSchema = z.object({
targetWorkspaceId: z.coerce.number().int().positive(),
destinationOwnerEmail: z.string().trim().email().max(255),
});
const birdSchema = z.object({
@@ -594,6 +591,68 @@ const issueMagicLinkInvite = async ({
});
};
const issueBirdTransferInvite = async ({
email,
birdName,
sourceWorkspaceName,
redirectTo = frontendBaseUrl,
}: {
email: string;
birdName: string;
sourceWorkspaceName: string;
redirectTo?: string;
}) => {
await deleteExpiredMagicLinkTokens();
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await createMagicLinkToken(email, null, tokenHash, redirectTo, expiresAt);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
const magicLinkUrl = verifyUrl.toString();
const subject = `${sourceWorkspaceName} sent you a bird transfer in FlockPal`;
const text = [
'Hi there,',
'',
`${sourceWorkspaceName} wants to transfer ${birdName} to your FlockPal account.`,
'Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.',
magicLinkUrl,
'',
'This link expires in 15 minutes and can only be used once.',
].join('\n');
if (!mailTransport) {
console.log(`Bird transfer invite for ${email}: ${magicLinkUrl}`);
return {
delivered: false,
previewUrl: magicLinkUrl,
};
}
await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: email,
subject,
text,
html: `
<p>Hi there,</p>
<p><strong>${escapeHtml(sourceWorkspaceName)}</strong> wants to transfer <strong>${escapeHtml(birdName)}</strong> to your FlockPal account.</p>
<p>Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.</p>
<p><a href="${magicLinkUrl}">Accept bird transfer in FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
});
return {
delivered: true,
previewUrl: null,
};
};
const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) {
return '';
@@ -747,53 +806,6 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
}
});
app.post('/api/transfers/draft', requireAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
const parsed = transferDraftSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid transfer draft payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(parsed.data.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'That bird could not be found in this flock.' });
return;
}
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
const existingUser = await findUserByEmail(destinationOwnerEmail);
let invitePreviewUrl: string | null = null;
let inviteDelivery: 'email' | 'preview' | null = null;
if (!existingUser) {
const delivery = await issueMagicLinkInvite({
email: destinationOwnerEmail,
name: null,
redirectTo: frontendBaseUrl,
});
invitePreviewUrl = delivery.previewUrl;
inviteDelivery = delivery.delivered ? 'email' : 'preview';
}
res.status(201).json({
ok: true,
bird: normalizeBird(bird),
destinationOwnerEmail,
destinationOwnerExists: Boolean(existingUser),
inviteSent: !existingUser,
invitePreviewUrl,
inviteDelivery,
});
} catch (error) {
next(error);
}
});
app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => {
const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : '';
@@ -819,8 +831,10 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next:
}
await claimWorkspaceInvites(user!);
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
const memberships = await normalizeWorkspaceMembershipList(user!.id);
const activeWorkspaceId = memberships[0]?.workspace.id ?? (await ensurePersonalWorkspaceForUser(user!));
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl);
redirectUrl.searchParams.set('auth_token', token);
@@ -1026,6 +1040,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
await linkAuthAccount(user!.id, providerKey, providerSubject, email);
await claimWorkspaceInvites(user!);
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
redirectUrl.searchParams.set('auth_token', token);
@@ -1410,27 +1425,59 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
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);
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!targetMembership) {
res.status(403).json({ error: 'You do not have access to that destination flock.' });
if (!sourceBird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, parsed.data.targetWorkspaceId);
const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id);
if (!targetWorkspaces.length) {
await createPendingBirdTransfer({
birdId: sourceBird.id,
sourceWorkspaceId: req.auth!.workspace.id,
destinationOwnerEmail,
requestedByUserId: req.auth!.user.id,
});
const delivery = await issueBirdTransferInvite({
email: destinationOwnerEmail,
birdName: sourceBird.name,
sourceWorkspaceName: req.auth!.workspace.name,
redirectTo: frontendBaseUrl,
});
res.status(202).json({
ok: true,
bird: normalizeBird(sourceBird),
destinationOwnerEmail,
inviteSent: true,
invitePreviewUrl: delivery.previewUrl,
inviteDelivery: delivery.delivered ? 'email' : 'preview',
message:
'A bird transfer invite was sent. The bird will stay in this flock until the recipient signs in, then FlockPal will automatically move it to their receiving flock.',
});
return;
}
if (targetWorkspaces.length > 1) {
res.status(409).json({ error: 'That owner email has more than one flock. Ask the receiving owner to use a unique owner email before transferring.' });
return;
}
const targetWorkspace = targetWorkspaces[0];
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, targetWorkspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.json({ bird: normalizeBird(bird) });
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} 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.' });
+25
View File
@@ -216,6 +216,31 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
ON birds (workspace_id, tag_id);
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
destination_owner_email VARCHAR(255) NOT NULL,
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE pending_bird_transfers
ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS last_error TEXT;
CREATE INDEX IF NOT EXISTS idx_pending_bird_transfers_destination_email
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS weight_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
@@ -1,7 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBird, getBirdById, listWeightsForBird, transferBirdToWorkspace } from './birdRepository.js';
import {
completePendingBirdTransfersForOwner,
createBird,
createPendingBirdTransfer,
getBirdById,
listWeightsForBird,
transferBirdToWorkspace,
} from './birdRepository.js';
import { mockDb } from '../test/mockDb.js';
test('getBirdById returns null when the bird does not exist in the workspace', async () => {
@@ -101,3 +108,86 @@ test('transferBirdToWorkspace moves the bird to the target workspace', async ()
assert.deepEqual(calls[0].params, ['bird-1', 10, 22]);
assert.match(calls[0].text, /UPDATE birds/);
});
test('createPendingBirdTransfer stores an open transfer for auto-completion', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'transfer-1',
bird_id: 'bird-1',
source_workspace_id: 10,
destination_owner_email: 'receiver@example.com',
requested_by_user_id: 'user-1',
completed_at: null,
completed_workspace_id: null,
last_error: null,
created_at: '2026-04-15T00:00:00.000Z',
},
],
});
const transfer = await createPendingBirdTransfer({
birdId: 'bird-1',
sourceWorkspaceId: 10,
destinationOwnerEmail: 'receiver@example.com',
requestedByUserId: 'user-1',
});
assert.equal(transfer?.id, 'transfer-1');
assert.deepEqual(calls[0].params, ['bird-1', 10, 'receiver@example.com', 'user-1']);
assert.match(calls[0].text, /INSERT INTO pending_bird_transfers/);
assert.match(calls[0].text, /ON CONFLICT/);
});
test('completePendingBirdTransfersForOwner moves pending birds and marks completion', async () => {
const { calls } = mockDb(
{
rowCount: 1,
rows: [
{
id: 'transfer-1',
bird_id: 'bird-1',
source_workspace_id: 10,
destination_owner_email: 'receiver@example.com',
requested_by_user_id: 'user-1',
completed_at: null,
completed_workspace_id: null,
last_error: null,
created_at: '2026-04-15T00:00:00.000Z',
},
],
},
{
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',
},
],
},
{ rowCount: 1, rows: [] },
);
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
assert.deepEqual(result, { completed: 1, failed: 0 });
assert.deepEqual(calls[0].params, ['receiver@example.com']);
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
});
+92 -1
View File
@@ -1,5 +1,5 @@
import { db } from '../db/client.js';
import type { BirdGender, BirdRow, VetVisitRow, WeightRow } from '../types.js';
import type { BirdGender, BirdRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js';
const birdSelectFields = `
birds.id,
@@ -195,6 +195,97 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
return result.rows[0] ?? null;
};
export const createPendingBirdTransfer = async ({
birdId,
sourceWorkspaceId,
destinationOwnerEmail,
requestedByUserId,
}: {
birdId: string;
sourceWorkspaceId: number;
destinationOwnerEmail: string;
requestedByUserId: string;
}) => {
const result = await db.query<PendingBirdTransferRow>(
`INSERT INTO pending_bird_transfers (bird_id, source_workspace_id, destination_owner_email, requested_by_user_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (bird_id) WHERE completed_at IS NULL DO UPDATE
SET destination_owner_email = EXCLUDED.destination_owner_email,
requested_by_user_id = EXCLUDED.requested_by_user_id,
last_error = NULL,
created_at = CURRENT_TIMESTAMP
RETURNING id, bird_id, source_workspace_id, destination_owner_email, requested_by_user_id, completed_at::text, completed_workspace_id, last_error, created_at`,
[birdId, sourceWorkspaceId, destinationOwnerEmail, requestedByUserId],
);
return result.rows[0] ?? null;
};
export const listPendingBirdTransfersForOwnerEmail = async (ownerEmail: string) => {
const result = await db.query<PendingBirdTransferRow>(
`SELECT id, bird_id, source_workspace_id, destination_owner_email, requested_by_user_id, completed_at::text, completed_workspace_id, last_error, created_at
FROM pending_bird_transfers
WHERE LOWER(destination_owner_email) = LOWER($1)
AND completed_at IS NULL
ORDER BY created_at ASC`,
[ownerEmail],
);
return result.rows;
};
export const markPendingBirdTransferCompleted = async (transferId: string, completedWorkspaceId: number) => {
await db.query(
`UPDATE pending_bird_transfers
SET completed_at = CURRENT_TIMESTAMP,
completed_workspace_id = $2,
last_error = NULL
WHERE id = $1`,
[transferId, completedWorkspaceId],
);
};
export const markPendingBirdTransferFailed = async (transferId: string, lastError: string) => {
await db.query(
`UPDATE pending_bird_transfers
SET last_error = $2
WHERE id = $1`,
[transferId, lastError],
);
};
export const completePendingBirdTransfersForOwner = async (ownerEmail: string, targetWorkspaceId: number) => {
const transfers = await listPendingBirdTransfersForOwnerEmail(ownerEmail);
let completed = 0;
let failed = 0;
for (const transfer of transfers) {
try {
const bird = await transferBirdToWorkspace(transfer.bird_id, transfer.source_workspace_id, targetWorkspaceId);
if (!bird) {
failed += 1;
await markPendingBirdTransferFailed(transfer.id, 'Bird is no longer available in the source flock.');
continue;
}
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
completed += 1;
} catch (error) {
failed += 1;
const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505'
? 'The receiving flock already has a bird using the same band/tag ID.'
: error instanceof Error
? error.message
: 'Unable to complete pending bird transfer.';
await markPendingBirdTransferFailed(transfer.id, message);
}
}
return { completed, failed };
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
@@ -1,7 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createWorkspace, deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, updateWorkspace } from './workspaceRepository.js';
import {
createWorkspace,
deleteWorkspaceIfEmpty,
ensurePersonalWorkspaceForUser,
findAlternateWorkspaceForUser,
listOwnedWorkspacesByOwnerEmail,
updateWorkspace,
} from './workspaceRepository.js';
import { mockDb } from '../test/mockDb.js';
import type { UserRow } from '../types.js';
@@ -141,3 +148,30 @@ test('findAlternateWorkspaceForUser returns another workspace when available', a
assert.equal(workspaceId, 84);
assert.deepEqual(calls[0].params, ['user-1', 42]);
});
test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 84,
name: 'Receiving Flock',
workspace_type: 'standard',
billing_email: 'receiver@example.com',
billing_plan: 'household_basic',
subscription_status: 'active',
rescue_verification_status: 'not_required',
created_at: '2026-04-14T00:00:00.000Z',
updated_at: '2026-04-14T00:00:00.000Z',
},
],
});
const workspaces = await listOwnedWorkspacesByOwnerEmail('Receiver@Example.com', 42);
assert.equal(workspaces[0]?.id, 84);
assert.deepEqual(calls[0].params, ['Receiver@Example.com', 42]);
assert.match(calls[0].text, /workspace_members\.role = 'owner'/);
assert.match(calls[0].text, /accepted_at IS NOT NULL/);
assert.match(calls[0].text, /workspaces\.id <> \$2/);
});
@@ -250,6 +250,22 @@ export const findAlternateWorkspaceForUser = async (userId: string, excludeWorks
return result.rows[0] ? Number(result.rows[0].workspace_id) : null;
};
export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => {
const result = await db.query<WorkspaceRow>(
`SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at
FROM workspace_members
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
WHERE LOWER(COALESCE(workspace_members.invite_email, workspace_members.email)) = LOWER($1)
AND workspace_members.role = 'owner'
AND workspace_members.accepted_at IS NOT NULL
AND workspaces.id <> $2
ORDER BY workspaces.created_at ASC`,
[ownerEmail, excludeWorkspaceId],
);
return result.rows;
};
export const getWorkspaceBirdCount = async (workspaceId: number) => {
const birdCount = await db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
+12
View File
@@ -106,6 +106,18 @@ export type BirdRow = {
latest_recorded_on: string | null;
};
export type PendingBirdTransferRow = {
id: string;
bird_id: string;
source_workspace_id: number;
destination_owner_email: string;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
last_error: string | null;
created_at: string;
};
export type WeightRow = {
id: string;
bird_id: string;
+25 -43
View File
@@ -75,7 +75,6 @@ Endpoints that accept either browser session tokens or integration tokens:
- `/api/birds`
- `/api/birds/:birdId/weights`
- `/api/birds/:birdId/vet-visits`
- `/api/transfers/draft`
Read-only integration tokens can call read endpoints, but cannot call write endpoints.
@@ -522,40 +521,6 @@ Responses:
- `400` for missing or expired OAuth state
- `404` for unknown provider
### Transfers
#### `POST /api/transfers/draft`
Requires auth with write access. Prepares a bird transfer to another owner email and optionally triggers a magic-link invite for that email when no user exists yet.
Request body:
```json
{
"birdId": "uuid",
"destinationOwnerEmail": "new-owner@example.com",
"notes": "Optional draft note"
}
```
Response `201`:
```json
{
"ok": true,
"bird": {},
"destinationOwnerEmail": "new-owner@example.com",
"destinationOwnerExists": false,
"inviteSent": true,
"invitePreviewUrl": "http://localhost:5000/api/auth/magic-link/verify?token=...",
"inviteDelivery": "preview"
}
```
Possible errors:
- `404` if the bird is not in the active workspace
### Workspaces
#### `GET /api/workspaces`
@@ -776,35 +741,52 @@ Possible errors:
#### `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.
Requires a browser session, write access, and role `owner` or `assistant`. Transfers a bird from the active flock to a flock owned by the provided receiving owner email. The sender does not need access to the receiving flock.
Request body:
```json
{
"targetWorkspaceId": 1002
"destinationOwnerEmail": "new-owner@example.com"
}
```
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
- if the receiving owner email does not currently own a receiving flock, FlockPal creates a pending transfer, sends a bird-transfer invite, and returns `202`; the bird stays in the sender's flock until the recipient signs in
- pending transfers auto-complete when the recipient signs in; FlockPal creates or uses the recipient's personal flock as the receiving flock
- immediate transfer requires the receiving owner email to match an accepted `owner` of exactly one other flock
- when transferred, the bird keeps its existing weight and vet history because the record itself is reassigned
Response `200`:
```json
{
"bird": {}
"bird": {},
"destinationOwnerEmail": "new-owner@example.com",
"destinationWorkspace": {}
}
```
Response `202` when the receiving email does not currently own a receiving flock:
```json
{
"ok": true,
"bird": {},
"destinationOwnerEmail": "new-owner@example.com",
"inviteSent": true,
"invitePreviewUrl": "http://localhost:5000/api/auth/magic-link/verify?token=...",
"inviteDelivery": "preview",
"message": "A bird transfer invite was sent. The bird will stay in this flock until the recipient signs in, then FlockPal will automatically move it to their receiving flock."
}
```
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
- `400` if the payload is invalid
- `404` if the bird is not in the active flock
- `409` if that owner email owns more than one receiving flock
- `409` if the destination flock already has a bird using the same `tagId`
#### `DELETE /api/birds/:birdId`
+72 -120
View File
@@ -860,15 +860,16 @@ function App() {
reason: '',
notes: '',
});
const [mergeForm, setMergeForm] = useState({
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
const [flockTransferForm, setFlockTransferForm] = useState({
birdId: '',
targetWorkspaceId: '',
destinationOwnerEmail: '',
});
const [transferringBird, setTransferringBird] = useState(false);
const [transferError, setTransferError] = useState('');
const [transferNotice, setTransferNotice] = useState<{
message: string;
previewUrl?: string | null;
} | null>(null);
const [deletingBird, setDeletingBird] = useState(false);
const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
@@ -879,10 +880,6 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId],
);
const transferableWorkspaces = useMemo(
() => authSession?.workspaces.filter((entry) => entry.workspace.id !== workspace?.id) ?? [],
[authSession, workspace?.id],
);
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
@@ -2127,59 +2124,22 @@ function App() {
}
};
const handleMergeSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
try {
const response = await apiFetch('/transfers/draft', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mergeForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save transfer draft.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
destinationOwnerExists?: boolean;
inviteSent?: boolean;
}>(response)) ?? {};
const transferBirdName = data.bird?.name || birds.find((bird) => bird.id === mergeForm.birdId)?.name || 'bird';
const inviteCopy = data.inviteSent
? `\n\nA FlockPal invite was also sent to ${mergeForm.destinationOwnerEmail} because that email does not have an account yet.`
: data.destinationOwnerExists
? `\n\n${mergeForm.destinationOwnerEmail} already has a FlockPal account, so no invite was needed.`
: '';
window.alert(
`Transfer prep saved for ${transferBirdName}.${inviteCopy}\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`,
);
setMergeForm({
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save transfer draft.');
}
};
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (transferringBird) {
return;
}
setError('');
setTransferError('');
setTransferNotice(null);
setTransferringBird(true);
try {
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetWorkspaceId: Number(flockTransferForm.targetWorkspaceId),
destinationOwnerEmail: flockTransferForm.destinationOwnerEmail,
}),
});
@@ -2187,9 +2147,29 @@ function App() {
throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.'));
}
const data = (await readJsonSafely<{ bird?: Bird }>(response)) ?? {};
const data =
(await readJsonSafely<{
bird?: Bird;
inviteSent?: boolean;
invitePreviewUrl?: string | null;
message?: string;
}>(response)) ?? {};
const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird';
if (data.inviteSent) {
setTransferNotice({
message:
data.message ??
`A bird transfer invite was sent to ${flockTransferForm.destinationOwnerEmail}. The transfer will complete automatically after they sign in.`,
previewUrl: data.invitePreviewUrl,
});
setFlockTransferForm({
birdId: '',
destinationOwnerEmail: '',
});
return;
}
setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId));
setAllBirdWeights((current) => {
const next = { ...current };
@@ -2208,12 +2188,16 @@ function App() {
}
setFlockTransferForm({
birdId: '',
targetWorkspaceId: '',
destinationOwnerEmail: '',
});
window.alert(`${transferredBirdName} was moved to the selected flock.`);
window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.');
const message = submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.';
setTransferError(message);
setError(message);
} finally {
setTransferringBird(false);
}
};
@@ -3955,7 +3939,7 @@ function App() {
<div className="panel-header">
<div>
<p className="eyebrow">Settings</p>
<h2>Bird transfer prep</h2>
<h2>Bird transfer</h2>
</div>
<button
className="secondary-button"
@@ -3971,15 +3955,19 @@ function App() {
{expandedSettingsSection === 'transfer' ? (
<>
<p className="muted">
Move a bird to another flock you already belong to. This keeps the bird record, weight history, and vet visits attached while
changing which flock owns it.
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
vet visits attached while 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 })}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
required
>
<option value="">Select a bird from this flock</option>
@@ -3991,70 +3979,34 @@ function App() {
</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>
<form className="form-panel" onSubmit={handleMergeSubmit}>
<label>
Bird for external transfer prep
<select
value={mergeForm.birdId}
onChange={(event) => setMergeForm({ ...mergeForm, 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 owner email
Receiving flock owner email
<input
type="email"
value={mergeForm.destinationOwnerEmail}
onChange={(event) => setMergeForm({ ...mergeForm, destinationOwnerEmail: event.target.value })}
value={flockTransferForm.destinationOwnerEmail}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
placeholder="owner@example.com"
required
/>
</label>
<label>
Transfer notes
<textarea
rows={4}
value={mergeForm.notes}
onChange={(event) => setMergeForm({ ...mergeForm, notes: event.target.value })}
placeholder="Optional context for rescue release, adoption, or household transfer"
/>
</label>
<button className="primary-button" type="submit">
Save transfer draft
<button className="primary-button" type="submit" disabled={transferringBird}>
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
</button>
{transferError ? (
<p className="error-banner" role="alert">
{transferError}
</p>
) : null}
{transferNotice ? (
<article className="summary-card" role="status">
<strong>Pending transfer invite sent</strong>
<span>{transferNotice.message}</span>
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null}
</article>
) : null}
</form>
</>
) : null}