Zapier integration (#491)

* create zapier app

* install sanctum

* move OAuthProviderController

* make `api-external` middleware

* add zapier endpoints

* add tests

* token management

* zapier event handler

* add policy

* use `slug` instead of `id`

* wip

* check policies

* change api prefix to `external`

* ui tweaks

* validate token abilities

* open zapier URL

* zapier ui tweaks

* update zap

* Fix linting

* Added sample endpoints + minor UI changes

* Run PHP code linter

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Boris Lepikhin 2024-08-12 02:14:02 -07:00 committed by GitHub
parent 7ad62fb3ea
commit 517bccc695
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 5799 additions and 51 deletions

View File

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
use Illuminate\Support\Arr;
enum AccessTokenAbility: string
{
case ManageIntegrations = 'manage-integrations';
case ListForms = 'list-forms';
case ListWorkspaces = 'list-workspaces';
public static function values(): array
{
return array_map(
fn (AccessTokenAbility $case) => $case->value,
static::cases()
);
}
public static function allowed(array $abilities): array
{
return Arr::where(
$abilities,
fn (string $ability) => in_array($ability, static::values())
);
}
}

View File

@ -13,7 +13,6 @@ class FormZapierWebhookController extends Controller
*/
public function __construct()
{
// $this->middleware('subscribed');
$this->middleware('auth');
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use App\Http\Requests\Integration\Zapier\PollSubmissionRequest;
use App\Http\Requests\Zapier\CreateIntegrationRequest;
use App\Http\Requests\Zapier\DeleteIntegrationRequest;
use App\Integrations\Handlers\ZapierIntegration;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Tests\Helpers\FormSubmissionDataFactory;
class IntegrationController
{
use AuthorizesRequests;
public function store(CreateIntegrationRequest $request)
{
$form = $request->form();
$this->authorize('view', $form);
$form->integrations()
->create([
'integration_id' => 'zapier',
'status' => 'active',
'data' => [
'hook_url' => $request->input('hookUrl'),
],
]);
return response()->json();
}
public function destroy(DeleteIntegrationRequest $request)
{
$form = $request->form();
$this->authorize('view', $form);
$form
->integrations()
->where('data->hook_url', $request->input('hookUrl'))
->delete();
return response()->json();
}
public function poll(PollSubmissionRequest $request)
{
$form = $request->form();
$this->authorize('view', $form);
$lastSubmission = $form->submissions()->latest()->first();
if (!$lastSubmission) {
// Generate fake data when no previous submissions
$submissionData = (new FormSubmissionDataFactory($form))->asFormSubmissionData()->createSubmissionData();
}
return [ZapierIntegration::formatWebhookData($form, $submissionData ?? $lastSubmission->data)];
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use App\Http\Requests\Zapier\ListFormsRequest;
use App\Http\Resources\Zapier\FormResource;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class ListFormsController
{
use AuthorizesRequests;
public function __invoke(ListFormsRequest $request)
{
$workspace = $request->workspace();
$this->authorize('view', $workspace);
return FormResource::collection(
$workspace->forms()->get()
);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use App\Http\Resources\Zapier\WorkspaceResource;
use App\Models\Workspace;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
class ListWorkspacesController
{
use AuthorizesRequests;
public function __invoke()
{
$this->authorize('viewAny', Workspace::class);
return WorkspaceResource::collection(
Auth::user()->workspaces()->get()
);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use Illuminate\Support\Facades\Auth;
class ValidateAuthController
{
public function __invoke()
{
$user = Auth::user();
return [
'name' => $user->name,
'email' => $user->email,
];
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\OAuth;
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Resources\OAuthProviderResource;

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Enums\AccessTokenAbility;
use App\Http\Requests\CreateTokenRequest;
use App\Http\Resources\TokenResource;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\PersonalAccessToken;
class TokenController
{
use AuthorizesRequests;
public function index()
{
return TokenResource::collection(
Auth::user()->tokens()->get()
);
}
public function store(CreateTokenRequest $request)
{
$token = Auth::user()->createToken(
$request->input('name'),
AccessTokenAbility::allowed($request->input('abilities'))
);
return response()->json([
'token' => $token->plainTextToken,
]);
}
public function destroy(PersonalAccessToken $token)
{
$this->authorize('delete', $token);
$token->delete();
return response()->json();
}
}

View File

@ -64,6 +64,11 @@ class Kernel extends HttpKernel
SelfHostedCredentialsMiddleware::class,
ImpersonationMiddleware::class,
],
'api-external' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
@ -90,5 +95,8 @@ class Kernel extends HttpKernel
'pro-form' => \App\Http\Middleware\Form\ProForm::class,
'protected-form' => \App\Http\Middleware\Form\ProtectedForm::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
];
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateTokenRequest extends FormRequest
{
public function rules()
{
return [
'name' => [
'required',
'string',
],
'abilities' => [
'nullable',
'array'
]
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Integration\Zapier;
use App\Models\Forms\Form;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PollSubmissionRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'form_id' => [
'required',
Rule::exists(Form::getModel()->getTable(), 'slug'),
],
];
}
public function form(): Form
{
return Form::query()
->where('slug', $this->input('form_id'))
->firstOrFail();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Zapier;
use App\Models\Forms\Form;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CreateIntegrationRequest extends FormRequest
{
public function rules()
{
return [
'form_id' => [
'required',
Rule::exists(Form::getModel()->getTable(), 'slug'),
],
'hookUrl' => [
'required',
'url',
],
];
}
public function form(): Form
{
return Form::query()
->where('slug', $this->input('form_id'))
->firstOrFail();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Zapier;
use App\Models\Forms\Form;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class DeleteIntegrationRequest extends FormRequest
{
public function rules()
{
return [
'form_id' => [
'required',
Rule::exists(Form::getModel()->getTable(), 'slug'),
],
'hookUrl' => [
'required',
'url',
],
];
}
public function form(): Form
{
return Form::query()
->where('slug', $this->input('form_id'))
->firstOrFail();
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Zapier;
use App\Models\Workspace;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ListFormsRequest extends FormRequest
{
public function rules()
{
return [
'workspace_id' => [
'required',
Rule::exists(Workspace::getModel()->getTable(), 'id'),
],
];
}
public function workspace(): Workspace
{
return Workspace::findOrFail($this->input('workspace_id'));
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \Laravel\Sanctum\PersonalAccessToken $resource
*/
class TokenResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->resource->id,
'name' => $this->resource->name,
'abilities' => $this->resource->abilities,
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Zapier;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \App\Models\Forms\Form $resource
*/
class FormResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->resource->slug,
'name' => $this->resource->title,
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Zapier;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \App\Models\Workspace $resource
*/
class WorkspaceResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->resource->id,
'name' => $this->resource->name,
];
}
}

View File

@ -4,6 +4,7 @@ namespace App\Integrations\Handlers;
use App\Models\Integration\FormIntegration;
use App\Events\Forms\FormSubmitted;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegrationsEvent;
use App\Service\Forms\FormSubmissionFormatter;
use App\Service\Forms\FormLogicConditionChecker;
@ -34,7 +35,7 @@ abstract class AbstractIntegrationHandler
protected function logicConditionsMet(): bool
{
if (!$this->formIntegration->logic) {
if (!$this->formIntegration->logic || empty((array) $this->formIntegration->logic)) {
return true;
}
return FormLogicConditionChecker::conditionsMet(
@ -58,27 +59,7 @@ abstract class AbstractIntegrationHandler
*/
protected function getWebhookData(): array
{
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))
->useSignedUrlForFiles()
->showHiddenFields();
$formattedData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$formattedData[$field['name']] = $field['value'];
}
$data = [
'form_title' => $this->form->title,
'form_slug' => $this->form->slug,
'submission' => $formattedData,
];
if ($this->form->is_pro && $this->form->editable_submissions) {
$data['edit_link'] = $this->form->share_url . '?submission_id=' . Hashids::encode(
$this->submissionData['submission_id']
);
}
return $data;
return self::formatWebhookData($this->form, $this->submissionData);
}
final public function run(): void
@ -125,8 +106,40 @@ abstract class AbstractIntegrationHandler
return [];
}
public static function formatData(array $data): array
public static function formatWebhookData(Form $form, array $submissionData): array
{
$formatter = (new FormSubmissionFormatter($form, $submissionData))
->useSignedUrlForFiles()
->showHiddenFields();
// Old format - kept for retro-compatibility
$oldFormatData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$oldFormatData[$field['name']] = $field['value'];
}
// New format using ID
$formattedData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$formattedData[$field['id']] = [
'value' => $field['value'],
'name' => $field['name'],
];
}
$data = [
'form_title' => $form->title,
'form_slug' => $form->slug,
'submission' => $oldFormatData,
'data' => $formattedData,
'message' => 'Please do not use the `submission` field. It is deprecated and will be removed in the future.'
];
if ($form->is_pro && $form->editable_submissions) {
$data['edit_link'] = $form->share_url . '?submission_id=' . Hashids::encode(
$submissionData['submission_id']
);
}
return $data;
}
@ -143,4 +156,12 @@ abstract class AbstractIntegrationHandler
'message' => $e->getMessage()
];
}
/**
* Used in FormIntegrationRequest to format integration
*/
public static function formatData(array $data): array
{
return $data;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Integrations\Handlers;
use App\Events\Forms\FormSubmitted;
use App\Models\Integration\FormIntegration;
use Exception;
class ZapierIntegration extends AbstractIntegrationHandler
{
public function __construct(
protected FormSubmitted $event,
protected FormIntegration $formIntegration,
protected array $integration
) {
parent::__construct($event, $formIntegration, $integration);
}
public static function getValidationRules(): array
{
return [];
}
public static function isOAuthRequired(): bool
{
return false;
}
protected function getWebhookUrl(): string
{
if (!isset($this->integrationData->hook_url)) {
throw new Exception('The webhook URL is missing');
}
return $this->integrationData->hook_url;
}
protected function shouldRun(): bool
{
return parent::shouldRun() && $this->getWebhookUrl();
}
}

View File

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Laravel\Sanctum\HasApiTokens;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
@ -16,6 +17,7 @@ class User extends Authenticatable implements JWTSubject
use Billable;
use HasFactory;
use Notifiable;
use HasApiTokens;
public const ROLE_ADMIN = 'admin';
public const ROLE_USER = 'user';

View File

@ -0,0 +1,14 @@
<?php
namespace App\Policies;
use App\Models\User;
use Laravel\Sanctum\PersonalAccessToken;
class PersonalAccessTokenPolicy
{
public function delete(User $user, PersonalAccessToken $token)
{
return $token->tokenable()->is($user);
}
}

View File

@ -10,9 +10,11 @@ use App\Models\Workspace;
use App\Policies\FormPolicy;
use App\Policies\Integration\FormZapierWebhookPolicy;
use App\Policies\OAuthProviderPolicy;
use App\Policies\PersonalAccessTokenPolicy;
use App\Policies\TemplatePolicy;
use App\Policies\WorkspacePolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Sanctum\PersonalAccessToken;
class AuthServiceProvider extends ServiceProvider
{
@ -27,6 +29,7 @@ class AuthServiceProvider extends ServiceProvider
FormZapierWebhook::class => FormZapierWebhookPolicy::class,
Template::class => TemplatePolicy::class,
OAuthProvider::class => OAuthProviderPolicy::class,
PersonalAccessToken::class => PersonalAccessTokenPolicy::class,
];
/**

View File

@ -33,6 +33,10 @@ class RouteServiceProvider extends ServiceProvider
Route::middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
Route::middleware('api-external')
->namespace($this->namespace)
->group(base_path('routes/api-external.php'));
});
}

View File

@ -7,6 +7,7 @@
<v-checkbox
:id="id ? id : name"
v-model="compVal"
:value="value"
:disabled="disabled ? true : null"
:name="name"
:color="color"
@ -48,6 +49,7 @@ export default {
components: { InputWrapper, VCheckbox },
props: {
...inputProps,
value: { type: [Boolean, String, Number, Object], required: false },
},
setup(props, context) {

View File

@ -3,6 +3,7 @@
<input
:id="id || name"
v-model="internalValue"
:value="value"
:name="name"
type="checkbox"
class="rounded border-gray-500 w-10 h-10 cursor-pointer checkbox"
@ -32,6 +33,7 @@ const props = defineProps({
id: { type: String, default: null },
name: { type: String, default: "checkbox" },
modelValue: { type: [Boolean, String], default: false },
value: { type: [Boolean, String, Number, Object], required: false },
disabled: { type: Boolean, default: false },
theme: {
type: Object, default: () => {

View File

@ -1,7 +1,7 @@
<template>
<div class="flex flex-wrap sm:flex-nowrap my-4 w-full">
<div
class="w-full sm:w-auto border border-gray-300 rounded-md p-2 flex-grow select-all bg-gray-100"
class="flex-1 truncate sm:w-auto border border-gray-300 rounded-md p-2 flex-grow select-all bg-gray-100"
>
<p class="select-all text-gray-900">
{{ content }}
@ -11,7 +11,7 @@
<v-button
color="light-gray"
class="w-full"
@click="copyToClipboard"
@click.prevent="copyToClipboard"
>
<slot name="icon">
<svg

View File

@ -92,6 +92,7 @@
</v-button>
</template>
<a
v-if="integrationTypeInfo.is_editable !== false"
v-track.edit_form_integration_click="{
form_slug: form.slug,
form_integration_id: integration.id,
@ -106,6 +107,21 @@
/>
Edit
</a>
<a
v-else-if="integrationTypeInfo.url"
v-track.edit_form_integration_click="{
form_slug: form.slug,
form_integration_id: integration.id,
}"
:href="integrationTypeInfo.url"
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center"
>
<Icon
name="heroicons:pencil"
class="w-5 h-5 mr-2"
/>
Edit on {{ integrationTypeInfo.name }}
</a>
<a
v-track.past_events_form_integration_click="{
form_slug: form.slug,

View File

@ -1,7 +1,7 @@
<template>
<UTooltip
:text="tooltipText"
:prevent="!unavailable"
:prevent="!unavailable || !tooltipText"
>
<div
v-track.new_integration_click="{ name: integration.id }"
@ -32,6 +32,11 @@
>
(coming soon)</span>
</div>
<Icon
v-if="integration.is_external"
class="inline h-4 w-4 ml-1 inline text-gray-500"
name="heroicons:arrow-top-right-on-square-20-solid"
/>
</div>
<pro-tag
v-if="integration?.is_pro === true"
@ -42,8 +47,10 @@
</template>
<script setup>
const emit = defineEmits(["select"])
import { computed } from 'vue'
import { useWorkspacesStore } from '@/stores/workspaces'
const emit = defineEmits(["select"])
const props = defineProps({
integration: {
type: Object,
@ -51,22 +58,25 @@ const props = defineProps({
},
})
const workspacesStore = useWorkspacesStore()
const currentWorkspace = computed(() => workspacesStore.getCurrent)
const unavailable = computed(() => {
return (
props.integration.coming_soon || props.integration.requires_subscription
props.integration.coming_soon ||
(props.integration.requires_subscription && !currentWorkspace.value.is_pro)
)
})
const tooltipText = computed(() => {
if (props.integration.coming_soon) return "This integration is coming soon"
if (props.integration.requires_subscription)
if (props.integration.requires_subscription && !currentWorkspace.value.is_pro )
return "You need a subscription to use this integration."
return ""
return null
})
const onClick = () => {
if (props.integration.coming_soon || props.integration.requires_subscription)
return
if (unavailable.value) return
emit("select", props.integration.id)
}
</script>
</script>

View File

@ -0,0 +1,83 @@
<template>
<div
class="text-gray-500 border shadow rounded-md p-5 mt-4 relative flex items-center"
>
<div class="flex-grow flex items-center">
<div>
<div class="flex space-x-3 font-semibold mr-2">
{{ token.name }}
</div>
<div class="">
<span
v-for="(ability, index) in token.abilities"
:key="index"
>
{{ accessTokenStore.getAbility(ability).title }}
<template v-if="index !== token.abilities.length - 1">
,&nbsp;
</template>
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<dropdown
class="inline"
>
<template #trigger="{ toggle }">
<v-button
color="white"
@click="toggle"
>
<Icon
name="heroicons:ellipsis-horizontal"
class="w-4 h-4 -mt-1"
/>
</v-button>
</template>
<a
href="#"
class="flex px-4 py-2 text-md text-red-600 hover:bg-red-50 hover:no-underline items-center"
@click.prevent="destroy"
>
<Icon
name="heroicons:trash"
class="w-5 h-5 mr-2"
/>
Delete
</a>
</dropdown>
</div>
</div>
</template>
<script setup>
const props = defineProps({
token: Object
})
const accessTokenStore = useAccessTokenStore()
const alert = useAlert()
function destroy() {
alert.confirm("Do you really want to delete this token?", () => {
opnFetch(`/settings/tokens/${props.token.id}`, {
method: 'DELETE'
})
.then(() => {
accessTokenStore.remove(props.token.id)
})
.catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while disconnecting an account")
}
})
})
}
</script>

View File

@ -0,0 +1,143 @@
<template>
<modal
:show="show"
max-width="lg"
@close="emit('close')"
>
<template #icon>
<svg
class="w-8 h-8"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V16M8 12H16M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<template #title>
Create an access token
</template>
<div class="px-4">
<form
@submit.prevent="createToken"
@keydown="form.onKeydown($event)"
>
<div v-if="!token">
<text-input
name="name"
class="mt-4"
:form="form"
:required="true"
label="Name"
/>
<div>
<label class="input-label">Abilities</label>
<div class="mt-2">
<checkbox-input
v-for="ability in abilities"
:key="ability.name"
v-model="form.abilities"
:value="ability.name"
:label="ability.title"
disabled
/>
</div>
</div>
</div>
<UAlert
v-if="token"
icon="i-heroicons-key-20-solid"
color="green"
variant="subtle"
title="Copy your access token"
description="Your token will only be shown once. Make sure to save it safely."
/>
<div class="flex">
<copy-content
v-if="token"
:content="token"
>
<template #icon>
<Icon
name="heroicons:link"
class="w-4 h-4 -mt-1 text-blue-600 mr-3"
/>
</template>
Copy Token
</copy-content>
</div>
<div class="w-full mt-6">
<v-button
v-if="!token"
:loading="form.busy"
class="w-full my-3"
>
Save
</v-button>
<v-button
v-else
class="w-full my-3"
@click.prevent="emit('close')"
>
Close
</v-button>
</div>
</form>
</div>
</modal>
</template>
<script setup>
import CopyContent from "../open/forms/components/CopyContent.vue"
const props = defineProps({
show: Boolean
})
const emit = defineEmits(['close'])
const accessTokenStore = useAccessTokenStore()
const abilities = computed(() => accessTokenStore.abilities)
const form = useForm({
name: "",
abilities: abilities.value.map(ability => ability.name),
})
const token = ref('')
function createToken() {
form.post("/settings/tokens").then((data) => {
// workspacesStore.save(data.workspace)
// workspacesStore.currentId = data.workspace.id
// workspaceModal.value = false
token.value = data.token
accessTokenStore.fetchTokens()
useAlert().success(
"Access token successfully created!",
)
})
}
watch(() => props.show, () => {
form.name = ''
form.abilities = abilities.value.map(ability => ability.name),
token.value = ''
})
</script>

View File

@ -31,17 +31,19 @@
"webhook": {
"name": "Webhook Notification",
"icon": "material-symbols:webhook",
"section_name": "Notifications",
"section_name": "Automation",
"file_name": "WebhookIntegration",
"is_pro": false
},
"zapier": {
"name": "Zapier Integration",
"name": "Zapier",
"icon": "cib:zapier",
"section_name": "Notifications",
"section_name": "Automation",
"file_name": "ZapierIntegration",
"is_pro": true,
"coming_soon": true
"is_pro": false,
"is_external": true,
"is_editable": false,
"url": "https://zapier.com/app/zaps"
},
"google_sheets": {
"name": "Google Sheets",

View File

@ -49,7 +49,7 @@
v-for="(sectionItem, sectionItemKey) in section"
:key="sectionItemKey"
:integration="sectionItem"
@select="openIntegrationModal"
@select="openIntegration"
/>
</div>
</div>
@ -107,11 +107,22 @@ onMounted(() => {
oAuthProvidersStore.fetchOAuthProviders(props.form.workspace_id)
})
const openIntegrationModal = (itemKey) => {
if (!itemKey || !integrations.value.has(itemKey))
const openIntegration = (itemKey) => {
if (!itemKey || !integrations.value.has(itemKey)) {
return alert.error("Integration not found")
if (integrations.value.get(itemKey).coming_soon)
}
const integration = integrations.value.get(itemKey)
if (integration.coming_soon) {
return alert.warning("This integration is not available yet")
}
if(integration.is_external && integration.url) {
window.open(integration.url, '_blank')
return
}
selectedIntegrationKey.value = itemKey
selectedIntegration.value = integrations.value.get(
selectedIntegrationKey.value,

View File

@ -66,6 +66,10 @@ const tabsList = computed(() => {
name: "Workspace Settings",
route: "settings-workspace",
},
{
name: "Access Tokens",
route: "settings-access-tokens",
},
{
name: "Connections",
route: "settings-connections",

View File

@ -0,0 +1,54 @@
<template>
<div>
<div class="flex items-center gap-y-4 flex-wrap-reverse">
<div class="flex-grow">
<h3 class="font-semibold text-2xl text-gray-900">
Access Tokens
</h3>
<small class="text-gray-600">Manage your access tokens keys.</small>
</div>
<UButton
label="Create new token"
icon="i-heroicons-plus"
:loading="loading"
@click="accessTokenModal = true"
/>
</div>
<div
v-if="loading"
class="w-full text-blue-500 text-center"
>
<Loader class="h-10 w-10 p-5" />
</div>
<div class="py-6">
<SettingsAccessTokenCard
v-for="token in tokens"
:key="token.id"
:token="token"
/>
</div>
<SettingsAccessTokenModal
:show="accessTokenModal"
@close="accessTokenModal = false"
/>
</div>
</template>
<script setup>
useOpnSeoMeta({
title: "Access Tokens",
})
const accessTokenModal = ref(false)
const accessTokenStore = useAccessTokenStore()
const tokens = computed(() => accessTokenStore.getAll)
const loading = computed(() => accessTokenStore.loading)
onMounted(() => {
accessTokenStore.fetchTokens()
})
</script>

47
client/stores/access_tokens.js vendored Normal file
View File

@ -0,0 +1,47 @@
import { defineStore } from "pinia"
import { useContentStore } from "~/composables/stores/useContentStore.js"
export const useAccessTokenStore = defineStore("access_tokens", () => {
const contentStore = useContentStore()
const abilities = [
{
title: 'Manage integrations',
name: 'manage-integrations',
},
{
title: 'List forms',
name: 'list-forms',
},
{
title: 'List workspaces',
name: 'list-workspaces',
},
]
const fetchTokens = () => {
contentStore.resetState()
contentStore.startLoading()
return opnFetch('/settings/tokens').then(
(data) => {
contentStore.save(data)
contentStore.stopLoading()
},
)
}
const tokens = computed(() => contentStore.getAll.value)
const getAbility = (name) => {
return abilities.find(ability => ability.name == name)
}
return {
...contentStore,
fetchTokens,
tokens,
abilities,
getAbility
}
})

View File

@ -22,6 +22,7 @@
"laravel/cashier": "*",
"laravel/framework": "^11.9",
"laravel/horizon": "*",
"laravel/sanctum": "^4.0",
"laravel/socialite": "*",
"laravel/tinker": "^2.9",
"laravel/ui": "*",
@ -48,6 +49,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "*",
"pestphp/pest": "^2.0",
"pestphp/pest-plugin-laravel": "^2.4",
"spatie/laravel-ignition": "*",
"spatie/laravel-ray": "*"
},

140
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d0db593e303db3e95ccf31168c9b66c7",
"content-hash": "b30e4ad9df3f7463d73bf25b757fd616",
"packages": [
{
"name": "amphp/amp",
@ -3501,6 +3501,70 @@
},
"time": "2024-06-17T13:58:22+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.0.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/9cfc0ce80cabad5334efff73ec856339e8ec1ac1",
"reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0",
"illuminate/contracts": "^11.0",
"illuminate/database": "^11.0",
"illuminate/support": "^11.0",
"php": "^8.2",
"symfony/console": "^7.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2024-04-10T19:39:58+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v1.3.3",
@ -12951,6 +13015,80 @@
],
"time": "2024-01-26T09:46:42+00:00"
},
{
"name": "pestphp/pest-plugin-laravel",
"version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest-plugin-laravel.git",
"reference": "53df51169a7f9595e06839cce638c73e59ace5e8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/53df51169a7f9595e06839cce638c73e59ace5e8",
"reference": "53df51169a7f9595e06839cce638c73e59ace5e8",
"shasum": ""
},
"require": {
"laravel/framework": "^10.48.9|^11.5.0",
"pestphp/pest": "^2.34.7",
"php": "^8.1.0"
},
"require-dev": {
"laravel/dusk": "^7.13.0",
"orchestra/testbench": "^8.22.3|^9.0.4",
"pestphp/pest-dev-tools": "^2.16.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Pest\\Laravel\\PestServiceProvider"
]
},
"pest": {
"plugins": [
"Pest\\Laravel\\Plugin"
]
}
},
"autoload": {
"files": [
"src/Autoload.php"
],
"psr-4": {
"Pest\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The Pest Laravel Plugin",
"keywords": [
"framework",
"laravel",
"pest",
"php",
"test",
"testing",
"unit"
],
"support": {
"source": "https://github.com/pestphp/pest-plugin-laravel/tree/v2.4.0"
},
"funding": [
{
"url": "https://www.paypal.com/paypalme/enunomaduro",
"type": "custom"
},
{
"url": "https://github.com/nunomaduro",
"type": "github"
}
],
"time": "2024-04-27T10:41:54+00:00"
},
{
"name": "phar-io/manifest",
"version": "2.0.4",

83
config/sanctum.php Normal file
View File

@ -0,0 +1,83 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->string('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

65
integrations/zapier/.gitignore vendored Normal file
View File

@ -0,0 +1,65 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# environment variables file
.env
.environment
# next.js build output
.next
# zapier app config
.zapierapprc

View File

@ -0,0 +1,45 @@
# Zapier
Install Zapier
```
npm install -g zapier-platform-cli
```
Install dependencies
```
cd `zapier`
npm install
```
Login to Zapier
```
zapier login
```
Register the app
```
zapier register [TITLE]
```
Publish the app
```
zapier push
```
Set the base URL to receive webhooks from Zapier. The version usually looks like 1.0.0.
```
zapier env:set [VERSION] BASE_URL=[BASE_URL]
```
## Testing
- Create an access token: http://localhost:3000/settings/access-tokens
- Create a Zap
- Authenticate using your token
- Submit a form

20
integrations/zapier/authentication.js vendored Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
type: 'custom',
test: {
removeMissingValuesFrom: { body: false, params: false },
url: '{{process.env.BASE_URL}}/external/zapier/validate',
},
fields: [
{
helpText:
'Enter your API key, located at https://opnform.com/settings/access-tokens',
computed: false,
key: 'api_key',
required: true,
label: 'API Key',
type: 'string',
},
],
connectionLabel: '{{bundle.inputData.name}} {{bundle.inputData.email}}',
customConfig: {},
};

24
integrations/zapier/index.js vendored Normal file
View File

@ -0,0 +1,24 @@
const authentication = require('./authentication');
const newSubmissionTrigger = require('./triggers/new_submission.js');
const listWorkspacesTrigger = require('./triggers/list_workspaces.js');
const listFormsTrigger = require('./triggers/list_forms.js');
module.exports = {
version: require('./package.json').version,
platformVersion: require('zapier-platform-core').version,
requestTemplate: {
headers: {
Authorization: 'Bearer {{bundle.authData.api_key}}',
'X-API-KEY': '{{bundle.authData.api_key}}',
},
params: { api_key: '{{bundle.authData.api_key}}' },
body: {},
},
authentication: authentication,
searches: {},
triggers: {
[newSubmissionTrigger.key]: newSubmissionTrigger,
[listWorkspacesTrigger.key]: listWorkspacesTrigger,
[listFormsTrigger.key]: listFormsTrigger,
},
};

3799
integrations/zapier/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "opnform-test-1",
"version": "1.0.1",
"description": "opnform-test-1",
"main": "index.js",
"scripts": {
"test": "jest --testTimeout 10000"
},
"engines": {
"node": ">=v18",
"npm": ">=5.6.0"
},
"dependencies": {
"zapier-platform-core": "15.8.0"
},
"devDependencies": {
"jest": "^29.6.0"
},
"private": true,
"zapier": {
"convertedByCLIVersion": "15.8.0"
}
}

View File

@ -0,0 +1,20 @@
const zapier = require('zapier-platform-core');
// Use this to make test calls into your app:
const App = require('../../index');
const appTester = zapier.createAppTester(App);
// read the `.env` file into the environment, if available
zapier.tools.env.inject();
describe('triggers.list_forms', () => {
it('should run', async () => {
const bundle = { inputData: {} };
const results = await appTester(
App.triggers['list_forms'].operation.perform,
bundle
);
expect(results).toBeDefined();
// TODO: add more assertions
});
});

View File

@ -0,0 +1,20 @@
const zapier = require('zapier-platform-core');
// Use this to make test calls into your app:
const App = require('../../index');
const appTester = zapier.createAppTester(App);
// read the `.env` file into the environment, if available
zapier.tools.env.inject();
describe('triggers.list_workspaces', () => {
it('should run', async () => {
const bundle = { inputData: {} };
const results = await appTester(
App.triggers['list_workspaces'].operation.perform,
bundle
);
expect(results).toBeDefined();
// TODO: add more assertions
});
});

View File

@ -0,0 +1,20 @@
const zapier = require('zapier-platform-core');
// Use this to make test calls into your app:
const App = require('../../index');
const appTester = zapier.createAppTester(App);
// read the `.env` file into the environment, if available
zapier.tools.env.inject();
describe('triggers.new_submission', () => {
it('should run', async () => {
const bundle = { inputData: {} };
const results = await appTester(
App.triggers['new_submission'].operation.perform,
bundle
);
expect(results).toBeDefined();
// TODO: add more assertions
});
});

View File

@ -0,0 +1,37 @@
module.exports = {
operation: {
perform: {
headers: { Accept: 'application/json' },
params: {
api_key: '{{bundle.authData.api_key}}',
workspace_id: '{{bundle.inputData.workspace_id}}',
},
removeMissingValuesFrom: { body: false, params: false },
url: '{{process.env.BASE_URL}}/external/zapier/forms',
},
inputFields: [
{
key: 'workspace_id',
type: 'string',
dynamic: 'list_workspaces.id.name',
label: 'Workspace',
required: true,
list: false,
altersDynamicFields: false,
},
],
sample: { id: 'my-form', name: 'My Form' },
outputFields: [
{ key: 'id', label: 'ID', type: 'string' },
{ key: 'name', label: 'Name', type: 'string' },
],
canPaginate: false,
},
display: {
description: 'Get the list of all forms',
hidden: true,
label: 'List Forms',
},
key: 'list_forms',
noun: 'Form',
};

View File

@ -0,0 +1,21 @@
module.exports = {
operation: {
perform: {
headers: { Accept: 'application/json' },
removeMissingValuesFrom: { body: false, params: false },
url: '{{process.env.BASE_URL}}/external/zapier/workspaces',
},
sample: { id: 1, name: 'My Workspace' },
outputFields: [
{ key: 'id', label: 'ID', type: 'integer' },
{ key: 'name', label: 'Name', type: 'string' },
],
},
display: {
description: "Get the list of all user's workspaces",
hidden: true,
label: 'List Workspaces',
},
key: 'list_workspaces',
noun: 'Workspace',
};

View File

@ -0,0 +1,81 @@
const perform = async (z, bundle) => {
return [bundle.cleanedRequest];
};
const performList = async (z, bundle) => {
// Replace with the actual URL that returns recent submissions
const response = await z.request({
url: `${process.env.BASE_URL}/external/zapier/submissions/recent`,
params: {
form_id: bundle.inputData.form_id,
},
});
// Ensure the structure of the response matches the webhook data structure
return response.data;
};
module.exports = {
operation: {
perform: perform,
performList: performList,
sample: {
"form_title": "Your form title",
"form_slug": "your-form-slug-og4lhg"
},
inputFields: [
{
key: 'workspace_id',
type: 'string',
label: 'Workspace',
dynamic: 'list_workspaces.id.name',
required: true,
list: false,
altersDynamicFields: true,
},
{
key: 'form_id',
type: 'string',
label: 'Form',
dynamic: 'list_forms.id.name',
required: true,
list: false,
altersDynamicFields: false,
},
],
type: 'hook',
performUnsubscribe: {
body: {
hookUrl: '{{bundle.subscribeData.id}}',
form_id: '{{bundle.inputData.form_id}}',
},
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
method: 'DELETE',
removeMissingValuesFrom: { body: false, params: false },
url: '{{process.env.BASE_URL}}/external/zapier/webhook',
},
performSubscribe: {
body: {
hookUrl: '{{bundle.targetUrl}}',
form_id: '{{bundle.inputData.form_id}}',
},
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
method: 'POST',
removeMissingValuesFrom: { body: false, params: false },
url: '{{process.env.BASE_URL}}/external/zapier/webhook',
},
},
display: {
description: 'Triggers when a new submission is created.',
hidden: false,
label: 'New Submission',
},
key: 'new_submission',
noun: 'Submission',
};

View File

@ -31,17 +31,19 @@
"webhook": {
"name": "Webhook Notification",
"icon": "material-symbols:webhook",
"section_name": "Notifications",
"section_name": "Automation",
"file_name": "WebhookIntegration",
"is_pro": false
},
"zapier": {
"name": "Zapier Integration",
"name": "Zapier",
"icon": "cib:zapier",
"section_name": "Notifications",
"section_name": "Automation",
"file_name": "ZapierIntegration",
"is_pro": true,
"coming_soon": true
"is_pro": false,
"is_external": true,
"is_editable": false,
"url": "https://zapier.com/app/zaps"
},
"google_sheets": {
"name": "Google Sheets",

38
routes/api-external.php Normal file
View File

@ -0,0 +1,38 @@
<?php
/**
* External API calls
*/
use App\Http\Controllers\Integrations\Zapier;
use App\Http\Controllers\Integrations\Zapier\ListFormsController;
use App\Http\Controllers\Integrations\Zapier\ListWorkspacesController;
Route::prefix('external')
->middleware('auth:sanctum')
->group(function () {
Route::prefix('zapier')->name('zapier.')->group(function () {
Route::get('validate', Zapier\ValidateAuthController::class)
->name('validate');
// Set and delete webhooks / manage integrations
Route::middleware('ability:manage-integrations')
->name('webhooks.')
->group(function () {
Route::post('webhook', [Zapier\IntegrationController::class, 'store'])
->name('store');
Route::delete('webhook', [Zapier\IntegrationController::class, 'destroy'])
->name('destroy');
Route::get('submissions/recent', [Zapier\IntegrationController::class, 'poll'])->name('poll');
});
Route::get('workspaces', ListWorkspacesController::class)
->middleware('ability:list-workspaces')
->name('workspaces');
Route::get('forms', ListFormsController::class)
->middleware('ability:list-forms')
->name('forms');
});
});

View File

@ -15,9 +15,10 @@ use App\Http\Controllers\Forms\Integration\FormIntegrationsEventController;
use App\Http\Controllers\Forms\Integration\FormZapierWebhookController;
use App\Http\Controllers\Forms\PublicFormController;
use App\Http\Controllers\Forms\RecordController;
use App\Http\Controllers\OAuth\OAuthProviderController;
use App\Http\Controllers\Settings\OAuthProviderController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TokenController;
use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\TemplateController;
use App\Http\Controllers\UserInviteController;
@ -51,6 +52,12 @@ Route::group(['middleware' => 'auth:api'], function () {
Route::patch('/profile', [ProfileController::class, 'update']);
Route::patch('/password', [PasswordController::class, 'update']);
Route::prefix('/tokens')->name('tokens.')->group(function () {
Route::get('/', [TokenController::class, 'index'])->name('index');
Route::post('/', [TokenController::class, 'store'])->name('store');
Route::delete('{token}', [TokenController::class, 'destroy'])->name('destroy');
});
Route::prefix('/providers')->name('providers.')->group(function () {
Route::post('/connect/{service}', [OAuthProviderController::class, 'connect'])->name('connect');
Route::post('/callback/{service}', [OAuthProviderController::class, 'handleRedirect'])->name('callback');

View File

@ -4,6 +4,7 @@ use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Support\Facades\Mail;
it('creates confirmation emails with the submitted data', function () {
$this->withoutExceptionHandling();
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);

View File

@ -0,0 +1,244 @@
<?php
use App\Models\Integration\FormIntegration;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\Helpers\FormSubmissionDataFactory;
use function Pest\Laravel\assertDatabaseCount;
use function Pest\Laravel\delete;
use function Pest\Laravel\post;
use function PHPUnit\Framework\assertEquals;
test('create an integration', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form = createForm($user, $workspace, ['title' => 'First form']);
Sanctum::actingAs(
$user,
['manage-integrations']
);
$this->withoutExceptionHandling();
post(route('zapier.webhooks.store'), [
'form_id' => $form->slug,
'hookUrl' => $hookUrl = 'https://zapier.com/hook/test'
])
->assertOk();
assertDatabaseCount('form_integrations', 1);
$integration = FormIntegration::first();
assertEquals($form->id, $integration->form_id);
assertEquals($hookUrl, $integration->data->hook_url);
});
test('cannot create an integration without a corresponding ability', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form = createForm($user, $workspace, ['title' => 'First form']);
Sanctum::actingAs($user);
post(route('zapier.webhooks.store'), [
'form_id' => $form->slug,
'hookUrl' => 'https://zapier.com/hook/test'
])
->assertForbidden();
assertDatabaseCount('form_integrations', 0);
});
test('cannot create an integration for other users form', function () {
$user = User::factory()->create();
$user2 = User::factory()->create();
$workspace = createUserWorkspace($user2);
$form = createForm($user2, $workspace, ['title' => 'First form']);
Sanctum::actingAs($user);
post(route('zapier.webhooks.store'), [
'form_id' => $form->slug,
'hookUrl' => 'https://zapier.com/hook/test'
])
->assertForbidden();
assertDatabaseCount('form_integrations', 0);
});
test('delete an integration', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form = createForm($user, $workspace, ['title' => 'First form']);
Sanctum::actingAs(
$user,
['manage-integrations']
);
$integration = FormIntegration::factory()
->for($form)
->create([
'data' => [
'hook_url' => $hookUrl = 'https://zapier.com/hook/test'
]
]);
assertDatabaseCount('form_integrations', 1);
delete(route('zapier.webhooks.destroy', $integration), [
'form_id' => $form->slug,
'hookUrl' => $hookUrl,
])
->assertOk();
assertDatabaseCount('form_integrations', 0);
});
test('cannot delete an integration with an incorrect hook url', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form = createForm($user, $workspace, ['title' => 'First form']);
Sanctum::actingAs(
$user,
['manage-integrations']
);
$integration = FormIntegration::factory()
->for($form)
->create([
'data' => [
'hook_url' => 'https://zapier.com/hook/test'
]
]);
delete(route('zapier.webhooks.destroy', $integration), [
'form_id' => $form->slug,
'hookUrl' => 'https://google.com',
])
->assertOk();
assertDatabaseCount('form_integrations', 1);
});
test('poll for the latest submission', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form = createForm($user, $workspace, [
'properties' => [
[
'id' => 'title',
'name' => 'Name',
'type' => 'text',
'hidden' => false,
'required' => true,
'logic' => [
'conditions' => null,
'actions' => [],
],
],
[
'id' => 'age',
'name' => 'Age',
'type' => 'number',
'hidden' => false,
'required' => true,
],
],
]);
// Create a submission for the form
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
// Create a webhook integration for the form
$integration = FormIntegration::factory()
->for($form)
->create([
'data' => [
'hook_url' => 'https://zapier.com/hook/test'
]
]);
Sanctum::actingAs($user, ['view', 'manage-integrations']);
// Call the poll endpoint
$response = $this->getJson(route('zapier.webhooks.poll', ['form_id' => $form->slug]));
// Assert the response status is OK
$response->assertOk();
// Decode the response data
$responseData = $response->json()[0];
$receivedData = collect($responseData['data'])->values()->pluck('value')->toArray();
$this->assertEmpty(array_diff(array_values($formData), $receivedData));
});
test('make up a submission when polling without any submission', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form = createForm($user, $workspace, [
'properties' => [
[
'id' => 'title',
'name' => 'Name',
'type' => 'text',
'hidden' => false,
'required' => true,
'logic' => [
'conditions' => null,
'actions' => [],
],
],
[
'id' => 'age',
'name' => 'Age',
'type' => 'number',
'hidden' => false,
'required' => true,
],
],
]);
// Create a webhook integration for the form
$integration = FormIntegration::factory()
->for($form)
->create([
'data' => [
'hook_url' => 'https://zapier.com/hook/test'
]
]);
Sanctum::actingAs($user, ['view', 'manage-integrations']);
// Call the poll endpoint
$this->withoutExceptionHandling();
$response = $this->getJson(route('zapier.webhooks.poll', ['form_id' => $form->slug]));
// Assert the response status is OK
$response->assertOk();
// Decode the response data
$responseData = $response->json()[0];
ray($responseData);
ray($responseData);
$this->assertNotEmpty($responseData['data']);
$this->assertTrue(count($responseData['data']) == 2);
});

View File

@ -0,0 +1,61 @@
<?php
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\get;
test('list all forms of a given workspace', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form1 = createForm($user, $workspace, ['title' => 'First form']);
$form2 = createForm($user, $workspace, ['title' => 'Second form']);
Sanctum::actingAs(
$user,
['list-forms']
);
get(route('zapier.forms', ['workspace_id' => $workspace->id]))
->assertOk()
->assertJsonCount(2)
->assertJson([
[
'id' => $form1->slug,
'name' => $form1->title,
],
[
'id' => $form2->slug,
'name' => $form2->title,
],
]);
});
test('cannot list forms without a corresponding ability', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$form1 = createForm($user, $workspace, ['title' => 'First form']);
$form2 = createForm($user, $workspace, ['title' => 'Second form']);
Sanctum::actingAs($user);
get(route('zapier.forms', ['workspace_id' => $workspace->id]))
->assertForbidden();
});
test('cannot other users forms', function () {
$user = User::factory()->create();
$user2 = User::factory()->create();
$workspace = createUserWorkspace($user2);
$form1 = createForm($user, $workspace, ['title' => 'First form']);
$form2 = createForm($user, $workspace, ['title' => 'Second form']);
Sanctum::actingAs($user);
get(route('zapier.forms', ['workspace_id' => $workspace->id]))
->assertForbidden();
});

View File

@ -0,0 +1,39 @@
<?php
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\get;
test('list all workspaces of a user', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
$anotherUser = User::factory()->create();
$anotherWorkspace = createUserWorkspace($anotherUser);
Sanctum::actingAs(
$user,
['list-workspaces']
);
get(route('zapier.workspaces'))
->assertOk()
->assertJsonCount(1)
->assertJson([
[
'id' => $workspace->id,
'name' => $workspace->name,
]
]);
});
test('cannot list workspaces without a corresponding ability', function () {
$user = User::factory()->create();
$workspace = createUserWorkspace($user);
Sanctum::actingAs($user);
get(route('zapier.workspaces'))
->assertForbidden();
});

View File

@ -0,0 +1,24 @@
<?php
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\get;
test('validate auth', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
get(route('zapier.validate'))
->assertOk()
->assertJson([
'name' => $user->name,
'email' => $user->email,
]);
});
test('cannot validate auth with incorrect credentials', function () {
get(route('zapier.validate'))
->assertUnauthorized();
});

View File

@ -9,6 +9,12 @@ class FormSubmissionDataFactory
{
private ?Faker\Generator $faker;
/**
* If true, then format expected by answer endpoint
* otherwise, format of answer as we store it in the FormSubmission's data
*/
private bool $answerFormat = true;
public function __construct(private Form $form)
{
$this->faker = Faker\Factory::create();
@ -19,6 +25,12 @@ class FormSubmissionDataFactory
return (new self($form))->createSubmissionData($data);
}
public function asFormSubmissionData()
{
$this->answerFormat = false;
return $this;
}
public function createSubmissionData($mergeData = [])
{
$data = [];
@ -68,9 +80,23 @@ class FormSubmissionDataFactory
$data[$property['id']] = $value;
});
if (!$this->answerFormat) {
$data = $this->formatAsSubmissionData($data);
}
return array_merge($data, $mergeData);
}
private function formatAsSubmissionData($data)
{
collect($this->form->properties)->each(function ($property) use (&$data) {
if ($property['type'] === 'phone_number') {
$data[$property['id']] = '+33749119783';
}
});
return $data;
}
private function generateSelectValue($property)
{
$values = [];

View File

@ -11,6 +11,10 @@
|
*/
use App\Models\Forms\Form;
use App\Models\User;
use App\Models\Workspace;
uses(\Tests\TestCase::class)->in('Feature');
/*
@ -38,3 +42,13 @@ expect()->extend('toBeOne', function () {
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function createUserWorkspace(User $user): Workspace
{
return test()->createUserWorkspace($user);
}
function createForm(User $user, Workspace $workspace, array $data = []): Form
{
return test()->createForm($user, $workspace, $data);
}