diff --git a/backend/src/app.ts b/backend/src/app.ts
index ee3b677..1449972 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -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: `
+
Hi there,
+ ${escapeHtml(sourceWorkspaceName)} wants to transfer ${escapeHtml(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.
+ Accept bird transfer in FlockPal
+ This link expires in 15 minutes and can only be used once.
+ `,
+ });
+
+ 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.' });
diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts
index 9baa8a7..9887e7b 100644
--- a/backend/src/db/schema.ts
+++ b/backend/src/db/schema.ts
@@ -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,
diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts
index 8c2cc72..cd4f85a 100644
--- a/backend/src/repositories/birdRepository.test.ts
+++ b/backend/src/repositories/birdRepository.test.ts
@@ -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/);
+});
diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts
index a0f747f..75ca554 100644
--- a/backend/src/repositories/birdRepository.ts
+++ b/backend/src/repositories/birdRepository.ts
@@ -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(
+ `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(
+ `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(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts
index a554e43..91741db 100644
--- a/backend/src/repositories/workspaceRepository.test.ts
+++ b/backend/src/repositories/workspaceRepository.test.ts
@@ -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/);
+});
diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts
index 5345ee0..7fc0394 100644
--- a/backend/src/repositories/workspaceRepository.ts
+++ b/backend/src/repositories/workspaceRepository.ts
@@ -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(
+ `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
diff --git a/backend/src/types.ts b/backend/src/types.ts
index 873fc4a..543c11e 100644
--- a/backend/src/types.ts
+++ b/backend/src/types.ts
@@ -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;
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index bd72599..2fa2cf9 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -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`
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e41c177..8acb861 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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) => {
- 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) => {
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() {
Settings
-
Bird transfer prep
+
Bird transfer
- 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.
-
-
-
-
- For future owner-to-owner handoffs outside your own flocks, save a transfer draft below.
-
-
>
) : null}