fixed transfer process
This commit is contained in:
+112
-65
@@ -27,7 +27,9 @@ import {
|
|||||||
updateUserName,
|
updateUserName,
|
||||||
} from './repositories/authRepository.js';
|
} from './repositories/authRepository.js';
|
||||||
import {
|
import {
|
||||||
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
|
createPendingBirdTransfer,
|
||||||
createVetVisitForBird,
|
createVetVisitForBird,
|
||||||
createWeightForBird,
|
createWeightForBird,
|
||||||
deleteBird,
|
deleteBird,
|
||||||
@@ -54,6 +56,7 @@ import {
|
|||||||
getMembershipForUser,
|
getMembershipForUser,
|
||||||
getNextWorkspaceId,
|
getNextWorkspaceId,
|
||||||
getWorkspaceById,
|
getWorkspaceById,
|
||||||
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
listRescueWorkspacesForAdmin,
|
listRescueWorkspacesForAdmin,
|
||||||
listMembershipsForUser,
|
listMembershipsForUser,
|
||||||
listWorkspaceMembers,
|
listWorkspaceMembers,
|
||||||
@@ -156,14 +159,8 @@ const workspaceMemberSchema = z.object({
|
|||||||
role: workspaceRoleSchema,
|
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({
|
const flockTransferSchema = z.object({
|
||||||
targetWorkspaceId: z.coerce.number().int().positive(),
|
destinationOwnerEmail: z.string().trim().email().max(255),
|
||||||
});
|
});
|
||||||
|
|
||||||
const birdSchema = z.object({
|
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) => {
|
const readBearerToken = (authorizationHeader?: string) => {
|
||||||
if (!authorizationHeader) {
|
if (!authorizationHeader) {
|
||||||
return '';
|
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) => {
|
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() : '';
|
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!);
|
await claimWorkspaceInvites(user!);
|
||||||
|
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
||||||
|
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
|
||||||
const memberships = await normalizeWorkspaceMembershipList(user!.id);
|
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 { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
||||||
const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl);
|
const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl);
|
||||||
redirectUrl.searchParams.set('auth_token', token);
|
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 linkAuthAccount(user!.id, providerKey, providerSubject, email);
|
||||||
await claimWorkspaceInvites(user!);
|
await claimWorkspaceInvites(user!);
|
||||||
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
||||||
|
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
|
||||||
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
||||||
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
|
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
|
||||||
redirectUrl.searchParams.set('auth_token', token);
|
redirectUrl.searchParams.set('auth_token', token);
|
||||||
@@ -1410,27 +1425,59 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.targetWorkspaceId === req.auth!.workspace.id) {
|
|
||||||
res.status(400).json({ error: 'Choose a different destination flock.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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) {
|
if (!sourceBird) {
|
||||||
res.status(403).json({ error: 'You do not have access to that destination flock.' });
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
return;
|
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) {
|
if (!bird) {
|
||||||
res.status(404).json({ error: 'Bird not found.' });
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ bird: normalizeBird(bird) });
|
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
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.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
|
||||||
|
|||||||
@@ -216,6 +216,31 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
||||||
ON birds (workspace_id, 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 (
|
CREATE TABLE IF NOT EXISTS weight_records (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
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, transferBirdToWorkspace } from './birdRepository.js';
|
import {
|
||||||
|
completePendingBirdTransfersForOwner,
|
||||||
|
createBird,
|
||||||
|
createPendingBirdTransfer,
|
||||||
|
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 () => {
|
||||||
@@ -101,3 +108,86 @@ test('transferBirdToWorkspace moves the bird to the target workspace', async ()
|
|||||||
assert.deepEqual(calls[0].params, ['bird-1', 10, 22]);
|
assert.deepEqual(calls[0].params, ['bird-1', 10, 22]);
|
||||||
assert.match(calls[0].text, /UPDATE birds/);
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/client.js';
|
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 = `
|
const birdSelectFields = `
|
||||||
birds.id,
|
birds.id,
|
||||||
@@ -195,6 +195,97 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
return result.rows[0] ?? null;
|
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) => {
|
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,14 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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 { mockDb } from '../test/mockDb.js';
|
||||||
import type { UserRow } from '../types.js';
|
import type { UserRow } from '../types.js';
|
||||||
|
|
||||||
@@ -141,3 +148,30 @@ test('findAlternateWorkspaceForUser returns another workspace when available', a
|
|||||||
assert.equal(workspaceId, 84);
|
assert.equal(workspaceId, 84);
|
||||||
assert.deepEqual(calls[0].params, ['user-1', 42]);
|
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;
|
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) => {
|
export const getWorkspaceBirdCount = async (workspaceId: number) => {
|
||||||
const birdCount = await db.query<{ count: string }>(
|
const birdCount = await db.query<{ count: string }>(
|
||||||
`SELECT COUNT(*)::text AS count
|
`SELECT COUNT(*)::text AS count
|
||||||
|
|||||||
@@ -106,6 +106,18 @@ export type BirdRow = {
|
|||||||
latest_recorded_on: string | null;
|
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 = {
|
export type WeightRow = {
|
||||||
id: string;
|
id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
|
|||||||
+25
-43
@@ -75,7 +75,6 @@ Endpoints that accept either browser session tokens or integration tokens:
|
|||||||
- `/api/birds`
|
- `/api/birds`
|
||||||
- `/api/birds/:birdId/weights`
|
- `/api/birds/:birdId/weights`
|
||||||
- `/api/birds/:birdId/vet-visits`
|
- `/api/birds/:birdId/vet-visits`
|
||||||
- `/api/transfers/draft`
|
|
||||||
|
|
||||||
Read-only integration tokens can call read endpoints, but cannot call write endpoints.
|
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
|
- `400` for missing or expired OAuth state
|
||||||
- `404` for unknown provider
|
- `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
|
### Workspaces
|
||||||
|
|
||||||
#### `GET /api/workspaces`
|
#### `GET /api/workspaces`
|
||||||
@@ -776,35 +741,52 @@ Possible errors:
|
|||||||
|
|
||||||
#### `POST /api/birds/:birdId/transfer`
|
#### `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:
|
Request body:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"targetWorkspaceId": 1002
|
"destinationOwnerEmail": "new-owner@example.com"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- the destination flock must be different from the current flock
|
- 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
|
||||||
- the signed-in user must already be a member of the destination flock
|
- pending transfers auto-complete when the recipient signs in; FlockPal creates or uses the recipient's personal flock as the receiving flock
|
||||||
- the bird keeps its existing weight and vet history because the record itself is reassigned
|
- 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`:
|
Response `200`:
|
||||||
|
|
||||||
```json
|
```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:
|
Possible errors:
|
||||||
|
|
||||||
- `400` if the destination flock is the current flock or the payload is invalid
|
- `400` if 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
|
- `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`
|
- `409` if the destination flock already has a bird using the same `tagId`
|
||||||
|
|
||||||
#### `DELETE /api/birds/:birdId`
|
#### `DELETE /api/birds/:birdId`
|
||||||
|
|||||||
+72
-120
@@ -860,15 +860,16 @@ function App() {
|
|||||||
reason: '',
|
reason: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
const [mergeForm, setMergeForm] = useState({
|
|
||||||
birdId: '',
|
|
||||||
destinationOwnerEmail: '',
|
|
||||||
notes: '',
|
|
||||||
});
|
|
||||||
const [flockTransferForm, setFlockTransferForm] = useState({
|
const [flockTransferForm, setFlockTransferForm] = useState({
|
||||||
birdId: '',
|
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 [deletingBird, setDeletingBird] = useState(false);
|
||||||
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||||
@@ -879,10 +880,6 @@ 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],
|
||||||
@@ -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>) => {
|
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (transferringBird) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
|
setTransferError('');
|
||||||
|
setTransferNotice(null);
|
||||||
|
setTransferringBird(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
|
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
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.'));
|
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';
|
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));
|
setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId));
|
||||||
setAllBirdWeights((current) => {
|
setAllBirdWeights((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
@@ -2208,12 +2188,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
setFlockTransferForm({
|
setFlockTransferForm({
|
||||||
birdId: '',
|
birdId: '',
|
||||||
targetWorkspaceId: '',
|
destinationOwnerEmail: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
window.alert(`${transferredBirdName} was moved to the selected flock.`);
|
window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`);
|
||||||
} catch (submitError) {
|
} 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 className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Settings</p>
|
<p className="eyebrow">Settings</p>
|
||||||
<h2>Bird transfer prep</h2>
|
<h2>Bird transfer</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
@@ -3971,15 +3955,19 @@ function App() {
|
|||||||
{expandedSettingsSection === 'transfer' ? (
|
{expandedSettingsSection === 'transfer' ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<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
|
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
|
||||||
changing which flock owns it.
|
vet visits attached while changing which flock owns it.
|
||||||
</p>
|
</p>
|
||||||
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
|
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Bird to move
|
Bird to move
|
||||||
<select
|
<select
|
||||||
value={flockTransferForm.birdId}
|
value={flockTransferForm.birdId}
|
||||||
onChange={(event) => setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value })}
|
onChange={(event) => {
|
||||||
|
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
|
||||||
|
setTransferError('');
|
||||||
|
setTransferNotice(null);
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select a bird from this flock</option>
|
<option value="">Select a bird from this flock</option>
|
||||||
@@ -3991,70 +3979,34 @@ function App() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Destination flock
|
Receiving flock owner email
|
||||||
<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
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={mergeForm.destinationOwnerEmail}
|
value={flockTransferForm.destinationOwnerEmail}
|
||||||
onChange={(event) => setMergeForm({ ...mergeForm, destinationOwnerEmail: event.target.value })}
|
onChange={(event) => {
|
||||||
|
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
|
||||||
|
setTransferError('');
|
||||||
|
setTransferNotice(null);
|
||||||
|
}}
|
||||||
|
placeholder="owner@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<button className="primary-button" type="submit" disabled={transferringBird}>
|
||||||
Transfer notes
|
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
|
||||||
<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>
|
</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>
|
</form>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user