Adding api automation workflow
This commit is contained in:
+94
-2
@@ -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<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 ({
|
||||
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,
|
||||
|
||||
+185
-12
@@ -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
|
||||
<select
|
||||
value={workspaceForm.workspaceType}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const workspaceType = event.target.value as WorkspaceFormState['workspaceType'];
|
||||
setWorkspaceForm({
|
||||
...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="rescue">Rescue</option>
|
||||
@@ -4567,6 +4600,72 @@ function App() {
|
||||
</span>
|
||||
</article>
|
||||
) : 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' ? (
|
||||
<article className="summary-card">
|
||||
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
||||
@@ -4958,12 +5057,20 @@ function App() {
|
||||
Flock type
|
||||
<select
|
||||
value={workspaceCreateForm.workspaceType}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const workspaceType = event.target.value as WorkspaceCreateFormState['workspaceType'];
|
||||
setWorkspaceCreateForm({
|
||||
...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="rescue">Rescue</option>
|
||||
@@ -5011,10 +5118,76 @@ function App() {
|
||||
</article>
|
||||
</>
|
||||
) : (
|
||||
<article className="summary-card">
|
||||
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
||||
<span>No billing is applied to rescue flocks.</span>
|
||||
</article>
|
||||
<>
|
||||
<section className="settings-nested-card">
|
||||
<h3>Rescue onboarding</h3>
|
||||
<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>
|
||||
Billing contact email
|
||||
|
||||
Reference in New Issue
Block a user