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.' });