diff --git a/backend/src/app.ts b/backend/src/app.ts index c3a948f..2b031da 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -176,6 +176,13 @@ const billingIntervalSchema = z.enum(['monthly', 'yearly']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const birdGenderSchema = z.enum(['unknown', 'male', 'female']); const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); +const rescueOnboardingSchema = z.object({ + name: z.string().trim().max(160).optional().or(z.literal('')), + city: z.string().trim().max(120).optional().or(z.literal('')), + state: z.string().trim().max(80).optional().or(z.literal('')), + ein: z.string().trim().max(32).optional().or(z.literal('')), + website: z.string().trim().url().max(2000).optional().or(z.literal('')), +}); const workspaceSchema = z.object({ name: z.string().trim().min(1).max(160), @@ -183,6 +190,7 @@ const workspaceSchema = z.object({ billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional(), + rescueOnboarding: rescueOnboardingSchema.optional(), }); const createWorkspaceSchema = z.object({ @@ -191,6 +199,7 @@ const createWorkspaceSchema = z.object({ billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional(), + rescueOnboarding: rescueOnboardingSchema.optional(), }); const workspaceMemberSchema = z.object({ @@ -328,6 +337,7 @@ const smtpPass = process.env.SMTP_PASS?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app'; +const rescueOnboardingWebhookUrl = 'https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee'; const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? ''; const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => { @@ -1044,6 +1054,54 @@ const sendRescueStatusNotification = async ({ return { delivered: true }; }; +type RescueOnboardingPayload = z.infer; + +const sendRescueOnboardingWebhook = async ({ + action, + workspaceId, + flockName, + ownerEmail, + requestedByUserId, + rescueOnboarding, +}: { + action: 'created' | 'converted'; + workspaceId: number; + flockName: string; + ownerEmail: string; + requestedByUserId: string; + rescueOnboarding: RescueOnboardingPayload; +}) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + try { + const response = await fetch(rescueOnboardingWebhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + Name: rescueOnboarding.name, + City: rescueOnboarding.city, + State: rescueOnboarding.state, + EIN: rescueOnboarding.ein, + Website: rescueOnboarding.website, + action, + workspaceId, + flockName, + ownerEmail, + requestedByUserId, + submittedAt: new Date().toISOString(), + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Rescue onboarding webhook returned ${response.status}`); + } + } finally { + clearTimeout(timeout); + } +}; + const issueMagicLinkInvite = async ({ email, name, @@ -1937,7 +1995,7 @@ app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, require } }); -app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { +app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body); if (!parsed.success) { @@ -2169,6 +2227,23 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request try { const workspaceId = await getNextWorkspaceId(); const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan); + + if (parsed.data.workspaceType === 'rescue') { + if (!parsed.data.rescueOnboarding) { + res.status(400).json({ error: 'Rescue onboarding details are required.' }); + return; + } + + await sendRescueOnboardingWebhook({ + action: 'created', + workspaceId, + flockName: parsed.data.name, + ownerEmail: req.auth!.user.email, + requestedByUserId: req.auth!.user.id, + rescueOnboarding: parsed.data.rescueOnboarding, + }); + } + const workspace = await createWorkspace({ id: workspaceId, name: parsed.data.name, @@ -2209,6 +2284,7 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole( const currentWorkspace = req.auth!.workspace; const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan ?? currentWorkspace.billing_plan); const billingInterval = parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? currentWorkspace.billing_interval); + const isConvertingToRescue = currentWorkspace.workspace_type !== 'rescue' && parsed.data.workspaceType === 'rescue'; const canUpdateWorkspace = isAdminUser(req.auth!.user) || subscriptionAllowsWrite(currentWorkspace) || @@ -2225,6 +2301,22 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole( return; } + if (isConvertingToRescue) { + if (!parsed.data.rescueOnboarding) { + res.status(400).json({ error: 'Rescue onboarding details are required.' }); + return; + } + + await sendRescueOnboardingWebhook({ + action: 'converted', + workspaceId: currentWorkspace.id, + flockName: parsed.data.name, + ownerEmail: req.auth!.user.email, + requestedByUserId: req.auth!.user.id, + rescueOnboarding: parsed.data.rescueOnboarding, + }); + } + const workspace = await updateWorkspace({ workspaceId: currentWorkspace.id, name: parsed.data.name, @@ -2234,7 +2326,7 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole( billingInterval, }); - if (workspace?.workspace_type === 'rescue' && currentWorkspace.workspace_type !== 'rescue') { + if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) { await sendRescueStatusNotification({ workspace, ownerEmail: req.auth!.user.email, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7a2184f..96a6149 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -194,12 +194,21 @@ type MemorializeBirdFormState = { notifyOnMemorialDay: boolean; }; +type RescueOnboardingFormState = { + name: string; + city: string; + state: string; + ein: string; + website: string; +}; + type WorkspaceFormState = { name: string; workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; billingInterval: BillingInterval; + rescueOnboarding: RescueOnboardingFormState; }; type WorkspaceMemberFormState = { @@ -214,6 +223,7 @@ type WorkspaceCreateFormState = { billingEmail: string; billingPlan: HouseholdBillingPlan; billingInterval: BillingInterval; + rescueOnboarding: RescueOnboardingFormState; }; type AuthFormState = { @@ -320,12 +330,21 @@ const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({ notifyOnMemorialDay: false, }); +const emptyRescueOnboardingForm = (): RescueOnboardingFormState => ({ + name: '', + city: '', + state: '', + ein: '', + website: '', +}); + const emptyWorkspaceForm: WorkspaceFormState = { name: 'My Flock', workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', billingInterval: 'monthly', + rescueOnboarding: emptyRescueOnboardingForm(), }; const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { @@ -340,6 +359,7 @@ const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { billingEmail: '', billingPlan: 'household_basic', billingInterval: 'monthly', + rescueOnboarding: emptyRescueOnboardingForm(), }; const emptyAuthForm: AuthFormState = { @@ -1594,6 +1614,7 @@ function App() { billingEmail: session.activeWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic', billingInterval: session.activeWorkspace.billingInterval, + rescueOnboarding: emptyRescueOnboardingForm(), }); setWorkspaceCreateForm((current) => ({ ...current, @@ -2250,6 +2271,7 @@ function App() { billingEmail: workspaceCreateForm.billingEmail, billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan, billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval, + rescueOnboarding: workspaceCreateForm.workspaceType === 'rescue' ? workspaceCreateForm.rescueOnboarding : undefined, }), }); @@ -3123,6 +3145,7 @@ function App() { ...workspaceForm, billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan, billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval, + rescueOnboarding: workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? workspaceForm.rescueOnboarding : undefined, }), }); @@ -3156,6 +3179,7 @@ function App() { billingEmail: savedWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingInterval: savedWorkspace.billingInterval, + rescueOnboarding: emptyRescueOnboardingForm(), }); return savedWorkspace; @@ -3259,6 +3283,7 @@ function App() { billingEmail: savedWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingInterval: savedWorkspace.billingInterval, + rescueOnboarding: emptyRescueOnboardingForm(), }); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.'); @@ -4547,12 +4572,20 @@ function App() { Flock type + setWorkspaceForm({ + ...workspaceForm, + rescueOnboarding: { ...workspaceForm.rescueOnboarding, name: event.target.value }, + }) + } + /> + + + + + + + ) : null} {workspaceForm.workspaceType === 'rescue' ? (
{formatBillingPlanName('rescue_free')} @@ -4958,12 +5057,20 @@ function App() { Flock type + setWorkspaceCreateForm({ + ...workspaceCreateForm, + rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, name: event.target.value }, + }) + } + /> + + + + + + +
+ {formatBillingPlanName('rescue_free')} + No billing is applied to rescue flocks. +
+ )}