fixed transfer process
This commit is contained in:
+112
-65
@@ -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.' });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user