Adding api automation workflow

This commit is contained in:
blaisadmin
2026-05-01 00:14:17 -04:00
parent 45fd507eeb
commit 5a3ca9021a
2 changed files with 279 additions and 14 deletions
+94 -2
View File
@@ -176,6 +176,13 @@ const billingIntervalSchema = z.enum(['monthly', 'yearly']);
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
const birdGenderSchema = z.enum(['unknown', 'male', 'female']); const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); 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({ const workspaceSchema = z.object({
name: z.string().trim().min(1).max(160), 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('')), billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
billingPlan: billingPlanSchema.optional(), billingPlan: billingPlanSchema.optional(),
billingInterval: billingIntervalSchema.optional(), billingInterval: billingIntervalSchema.optional(),
rescueOnboarding: rescueOnboardingSchema.optional(),
}); });
const createWorkspaceSchema = z.object({ const createWorkspaceSchema = z.object({
@@ -191,6 +199,7 @@ const createWorkspaceSchema = z.object({
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
billingPlan: billingPlanSchema.optional(), billingPlan: billingPlanSchema.optional(),
billingInterval: billingIntervalSchema.optional(), billingInterval: billingIntervalSchema.optional(),
rescueOnboarding: rescueOnboardingSchema.optional(),
}); });
const workspaceMemberSchema = z.object({ const workspaceMemberSchema = z.object({
@@ -328,6 +337,7 @@ const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app'; 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 stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? ''; const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => { const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => {
@@ -1044,6 +1054,54 @@ const sendRescueStatusNotification = async ({
return { delivered: true }; return { delivered: true };
}; };
type RescueOnboardingPayload = z.infer<typeof rescueOnboardingSchema>;
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 ({ const issueMagicLinkInvite = async ({
email, email,
name, 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); const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -2169,6 +2227,23 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
try { try {
const workspaceId = await getNextWorkspaceId(); const workspaceId = await getNextWorkspaceId();
const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan); 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({ const workspace = await createWorkspace({
id: workspaceId, id: workspaceId,
name: parsed.data.name, name: parsed.data.name,
@@ -2209,6 +2284,7 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
const currentWorkspace = req.auth!.workspace; const currentWorkspace = req.auth!.workspace;
const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan ?? currentWorkspace.billing_plan); 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 billingInterval = parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? currentWorkspace.billing_interval);
const isConvertingToRescue = currentWorkspace.workspace_type !== 'rescue' && parsed.data.workspaceType === 'rescue';
const canUpdateWorkspace = const canUpdateWorkspace =
isAdminUser(req.auth!.user) || isAdminUser(req.auth!.user) ||
subscriptionAllowsWrite(currentWorkspace) || subscriptionAllowsWrite(currentWorkspace) ||
@@ -2225,6 +2301,22 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
return; 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({ const workspace = await updateWorkspace({
workspaceId: currentWorkspace.id, workspaceId: currentWorkspace.id,
name: parsed.data.name, name: parsed.data.name,
@@ -2234,7 +2326,7 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
billingInterval, billingInterval,
}); });
if (workspace?.workspace_type === 'rescue' && currentWorkspace.workspace_type !== 'rescue') { if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) {
await sendRescueStatusNotification({ await sendRescueStatusNotification({
workspace, workspace,
ownerEmail: req.auth!.user.email, ownerEmail: req.auth!.user.email,
+185 -12
View File
@@ -194,12 +194,21 @@ type MemorializeBirdFormState = {
notifyOnMemorialDay: boolean; notifyOnMemorialDay: boolean;
}; };
type RescueOnboardingFormState = {
name: string;
city: string;
state: string;
ein: string;
website: string;
};
type WorkspaceFormState = { type WorkspaceFormState = {
name: string; name: string;
workspaceType: WorkspaceType; workspaceType: WorkspaceType;
billingEmail: string; billingEmail: string;
billingPlan: HouseholdBillingPlan; billingPlan: HouseholdBillingPlan;
billingInterval: BillingInterval; billingInterval: BillingInterval;
rescueOnboarding: RescueOnboardingFormState;
}; };
type WorkspaceMemberFormState = { type WorkspaceMemberFormState = {
@@ -214,6 +223,7 @@ type WorkspaceCreateFormState = {
billingEmail: string; billingEmail: string;
billingPlan: HouseholdBillingPlan; billingPlan: HouseholdBillingPlan;
billingInterval: BillingInterval; billingInterval: BillingInterval;
rescueOnboarding: RescueOnboardingFormState;
}; };
type AuthFormState = { type AuthFormState = {
@@ -320,12 +330,21 @@ const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
notifyOnMemorialDay: false, notifyOnMemorialDay: false,
}); });
const emptyRescueOnboardingForm = (): RescueOnboardingFormState => ({
name: '',
city: '',
state: '',
ein: '',
website: '',
});
const emptyWorkspaceForm: WorkspaceFormState = { const emptyWorkspaceForm: WorkspaceFormState = {
name: 'My Flock', name: 'My Flock',
workspaceType: 'standard', workspaceType: 'standard',
billingEmail: '', billingEmail: '',
billingPlan: 'household_basic', billingPlan: 'household_basic',
billingInterval: 'monthly', billingInterval: 'monthly',
rescueOnboarding: emptyRescueOnboardingForm(),
}; };
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
@@ -340,6 +359,7 @@ const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
billingEmail: '', billingEmail: '',
billingPlan: 'household_basic', billingPlan: 'household_basic',
billingInterval: 'monthly', billingInterval: 'monthly',
rescueOnboarding: emptyRescueOnboardingForm(),
}; };
const emptyAuthForm: AuthFormState = { const emptyAuthForm: AuthFormState = {
@@ -1594,6 +1614,7 @@ function App() {
billingEmail: session.activeWorkspace.billingEmail ?? '', billingEmail: session.activeWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic', billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic',
billingInterval: session.activeWorkspace.billingInterval, billingInterval: session.activeWorkspace.billingInterval,
rescueOnboarding: emptyRescueOnboardingForm(),
}); });
setWorkspaceCreateForm((current) => ({ setWorkspaceCreateForm((current) => ({
...current, ...current,
@@ -2250,6 +2271,7 @@ function App() {
billingEmail: workspaceCreateForm.billingEmail, billingEmail: workspaceCreateForm.billingEmail,
billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan, billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan,
billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval, billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval,
rescueOnboarding: workspaceCreateForm.workspaceType === 'rescue' ? workspaceCreateForm.rescueOnboarding : undefined,
}), }),
}); });
@@ -3123,6 +3145,7 @@ function App() {
...workspaceForm, ...workspaceForm,
billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan, billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan,
billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval, 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 ?? '', billingEmail: savedWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
billingInterval: savedWorkspace.billingInterval, billingInterval: savedWorkspace.billingInterval,
rescueOnboarding: emptyRescueOnboardingForm(),
}); });
return savedWorkspace; return savedWorkspace;
@@ -3259,6 +3283,7 @@ function App() {
billingEmail: savedWorkspace.billingEmail ?? '', billingEmail: savedWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
billingInterval: savedWorkspace.billingInterval, billingInterval: savedWorkspace.billingInterval,
rescueOnboarding: emptyRescueOnboardingForm(),
}); });
} catch (workspaceError) { } catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.'); setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.');
@@ -4547,12 +4572,20 @@ function App() {
Flock type Flock type
<select <select
value={workspaceForm.workspaceType} value={workspaceForm.workspaceType}
onChange={(event) => onChange={(event) => {
const workspaceType = event.target.value as WorkspaceFormState['workspaceType'];
setWorkspaceForm({ setWorkspaceForm({
...workspaceForm, ...workspaceForm,
workspaceType: event.target.value as WorkspaceFormState['workspaceType'], workspaceType,
}) rescueOnboarding:
} workspaceType === 'rescue'
? {
...workspaceForm.rescueOnboarding,
name: workspaceForm.rescueOnboarding.name || workspaceForm.name,
}
: workspaceForm.rescueOnboarding,
});
}}
> >
<option value="standard">Standard household</option> <option value="standard">Standard household</option>
<option value="rescue">Rescue</option> <option value="rescue">Rescue</option>
@@ -4567,6 +4600,72 @@ function App() {
</span> </span>
</article> </article>
) : null} ) : null}
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
<section className="settings-nested-card">
<h3>Rescue onboarding</h3>
<label>
Rescue Name
<input
value={workspaceForm.rescueOnboarding.name}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, name: event.target.value },
})
}
/>
</label>
<label>
City
<input
value={workspaceForm.rescueOnboarding.city}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, city: event.target.value },
})
}
/>
</label>
<label>
State
<input
value={workspaceForm.rescueOnboarding.state}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, state: event.target.value },
})
}
/>
</label>
<label>
EIN
<input
value={workspaceForm.rescueOnboarding.ein}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, ein: event.target.value },
})
}
/>
</label>
<label>
Website
<input
type="url"
value={workspaceForm.rescueOnboarding.website}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, website: event.target.value },
})
}
/>
</label>
</section>
) : null}
{workspaceForm.workspaceType === 'rescue' ? ( {workspaceForm.workspaceType === 'rescue' ? (
<article className="summary-card"> <article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong> <strong>{formatBillingPlanName('rescue_free')}</strong>
@@ -4958,12 +5057,20 @@ function App() {
Flock type Flock type
<select <select
value={workspaceCreateForm.workspaceType} value={workspaceCreateForm.workspaceType}
onChange={(event) => onChange={(event) => {
const workspaceType = event.target.value as WorkspaceCreateFormState['workspaceType'];
setWorkspaceCreateForm({ setWorkspaceCreateForm({
...workspaceCreateForm, ...workspaceCreateForm,
workspaceType: event.target.value as WorkspaceCreateFormState['workspaceType'], workspaceType,
}) rescueOnboarding:
} workspaceType === 'rescue'
? {
...workspaceCreateForm.rescueOnboarding,
name: workspaceCreateForm.rescueOnboarding.name || workspaceCreateForm.name,
}
: workspaceCreateForm.rescueOnboarding,
});
}}
> >
<option value="standard">Standard household</option> <option value="standard">Standard household</option>
<option value="rescue">Rescue</option> <option value="rescue">Rescue</option>
@@ -5011,10 +5118,76 @@ function App() {
</article> </article>
</> </>
) : ( ) : (
<article className="summary-card"> <>
<strong>{formatBillingPlanName('rescue_free')}</strong> <section className="settings-nested-card">
<span>No billing is applied to rescue flocks.</span> <h3>Rescue onboarding</h3>
</article> <label>
Rescue Name
<input
value={workspaceCreateForm.rescueOnboarding.name}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, name: event.target.value },
})
}
/>
</label>
<label>
City
<input
value={workspaceCreateForm.rescueOnboarding.city}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, city: event.target.value },
})
}
/>
</label>
<label>
State
<input
value={workspaceCreateForm.rescueOnboarding.state}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, state: event.target.value },
})
}
/>
</label>
<label>
EIN
<input
value={workspaceCreateForm.rescueOnboarding.ein}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, ein: event.target.value },
})
}
/>
</label>
<label>
Website
<input
type="url"
value={workspaceCreateForm.rescueOnboarding.website}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, website: event.target.value },
})
}
/>
</label>
</section>
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue flocks.</span>
</article>
</>
)} )}
<label> <label>
Billing contact email Billing contact email