Separated laravel app to its own folder (#540)

This commit is contained in:
Julien Nahum
2024-08-26 18:24:56 +02:00
committed by GitHub
parent 39b8df5eed
commit 5bd1dda504
546 changed files with 124 additions and 143 deletions

47
api/.env.docker Normal file
View File

@@ -0,0 +1,47 @@
APP_NAME="OpnForm"
APP_ENV=local
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
LOG_CHANNEL=errorlog
LOG_LEVEL=debug
FILESYSTEM_DRIVER=local
BROADCAST_CONNECTION=log
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
MAIL_MAILER=log
MAIL_HOST=
MAIL_PORT=
MAIL_USERNAME=your@email.com
MAIL_PASSWORD=
MAIL_ENCRYPTION=
MAIL_FROM_ADDRESS=your@email.com
MAIL_FROM_NAME=OpnForm
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
JWT_TTL=1440
JWT_SECRET=
MUX_WORKSPACE_ID=
MUX_API_TOKEN=
OPEN_AI_API_KEY=
SELF_HOSTED=true

89
api/.env.example Normal file
View File

@@ -0,0 +1,89 @@
APP_NAME="OpnForm"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_LOG_LEVEL=debug
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_LEVEL=debug
FRONT_URL=https://localhost:3000
FRONT_API_SECRET=secret
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=postgres
DB_USERNAME=postgres
DB_PASSWORD=postgres
FILESYSTEM_DRIVER=s3
FILESYSTEM_DISK=s3
BROADCAST_CONNECTION=log
CACHE_STORE=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
JWT_TTL=1440
JWT_SECRET=
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_PROD_DEFAULT_PRODUCT_ID=
STRIPE_PROD_DEFAULT_PRICING_MONTHLY=
STRIPE_PROD_DEFAULT_PRICING_YEARLY=
STRIPE_TEST_DEFAULT_PRODUCT_ID=
STRIPE_TEST_DEFAULT_PRICING_MONTHLY=
STRIPE_TEST_DEFAULT_PRICING_YEARLY=
H_CAPTCHA_SITE_KEY=
H_CAPTCHA_SECRET_KEY=
MUX_WORKSPACE_ID=
MUX_API_TOKEN=
ADMIN_EMAILS=
TEMPLATE_EDITOR_EMAILS=
OPEN_AI_API_KEY=
CADDY_SECRET=
CADDY_AUTHORIZED_IPS=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google
GOOGLE_AUTH_REDIRECT_URL=http://localhost:3000/oauth/google/callback
GOOGLE_FONTS_API_KEY=

34
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
/node_modules
/public/hot
/public/storage
/storage/*.key
/storage/clockwork
/vendor
.env
.env.production
.env.api-production
.env.backup
.env.testing
.env.dusk.local
.env.api-*
.env.docker.local
.phpunit.result.cache
.idea/*
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
phpunit.dusk.xml
/public/dist
/public/build
/public/mix-manifest.json
/public/sitemap.xml
.DS_Store
public/.DS_Store
*.DS_Store
.vapor/
.env.production
.env.staging
_ide_helper.php
docker-compose.override.yml
/.make.*

629
api/_ide_helper_models.php Normal file
View File

@@ -0,0 +1,629 @@
<?php
// @formatter:off
// phpcs:ignoreFile
/**
* A helper file for your Eloquent Models
* Copy the phpDocs from this file to the correct Model,
* And remove them from this file, to prevent double declarations.
*
* @author Barry vd. Heuvel <barryvdh@gmail.com>
*/
namespace App\Models\Billing{
/**
*
*
* @property int $id
* @property int $user_id
* @property string $type
* @property string $stripe_id
* @property string $stripe_status
* @property string|null $stripe_price
* @property int|null $quantity
* @property \Illuminate\Support\Carbon|null $trial_ends_at
* @property \Illuminate\Support\Carbon|null $ends_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Cashier\SubscriptionItem> $items
* @property-read int|null $items_count
* @property-read \App\Models\User|null $owner
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|Subscription active()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription canceled()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription ended()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription expiredTrial()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription incomplete()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription notCanceled()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnGracePeriod()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnTrial()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription onGracePeriod()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription onTrial()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription pastDue()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription query()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription recurring()
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereEndsAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereQuantity($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripePrice($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereTrialEndsAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereType($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUserId($value)
*/
class Subscription extends \Eloquent {}
}
namespace App\Models\Forms\AI{
/**
*
*
* @property int $id
* @property string $form_prompt
* @property string $status
* @property string|null $result
* @property string $ip
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion query()
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereFormPrompt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereIp($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereResult($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereUpdatedAt($value)
*/
class AiFormCompletion extends \Eloquent {}
}
namespace App\Models\Forms{
/**
*
*
* @property int $id
* @property int $workspace_id
* @property string $title
* @property string $slug
* @property array $properties
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string|null $description
* @property string $submit_button_text
* @property bool $re_fillable
* @property string $re_fill_button_text
* @property string $color
* @property bool $uppercase_labels
* @property bool $no_branding
* @property bool $hide_title
* @property string $submitted_text
* @property string $dark_mode
* @property string|null $logo_picture
* @property string|null $cover_picture
* @property string|null $redirect_url
* @property string|null $custom_code
* @property string $theme
* @property array|null $database_fields_update
* @property string $width
* @property bool $transparent_background
* @property \Illuminate\Support\Carbon|null $closes_at
* @property string|null $closed_text
* @property bool $use_captcha
* @property bool $can_be_indexed
* @property string|null $password
* @property array $tags
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property int $creator_id
* @property-read array $removed_properties
* @property int|null $max_submissions_count
* @property string|null $max_submissions_reached_text
* @property string $visibility
* @property bool $editable_submissions
* @property string $editable_submissions_button_text
* @property bool $confetti_on_submission
* @property object $seo_meta
* @property bool $auto_save
* @property string|null $custom_domain
* @property bool $show_progress_bar
* @property string $size
* @property string $border_radius
* @property-read \App\Models\User $creator
* @property-read mixed $edit_url
* @property-read mixed $form_pending_submission_key
* @property-read mixed $has_password
* @property-read mixed $is_closed
* @property-read mixed $is_pro
* @property-read mixed $max_file_size
* @property-read mixed $max_number_of_submissions_reached
* @property-read mixed $share_url
* @property-read int|null $submissions_count
* @property-read int|null $views_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Integration\FormIntegration> $integrations
* @property-read int|null $integrations_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\FormStatistic> $statistics
* @property-read int|null $statistics_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\FormSubmission> $submissions
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\FormView> $views
* @property-read \App\Models\Workspace|null $workspace
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Integration\FormZapierWebhook> $zappierHooks
* @property-read int|null $zappier_hooks_count
* @method static \Database\Factories\FormFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|Form newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Form newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Form onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Form query()
* @method static \Illuminate\Database\Eloquent\Builder|Form whereAutoSave($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereBorderRadius($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCanBeIndexed($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereClosedText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereClosesAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereConfettiOnSubmission($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCoverPicture($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatorId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomDomain($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDarkMode($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDatabaseFieldsUpdate($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissions($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissionsButtonText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereHideTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereLogoPicture($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsCount($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsReachedText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNoBranding($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereProperties($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillButtonText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillable($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereRedirectUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereRemovedProperties($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSeoMeta($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereShowProgressBar($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSize($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSlug($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmitButtonText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmittedText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTags($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTheme($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTransparentBackground($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereUppercaseLabels($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereUseCaptcha($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereVisibility($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereWidth($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereWorkspaceId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Form withoutTrashed()
*/
class Form extends \Eloquent implements \App\Models\Traits\CachableAttributes {}
}
namespace App\Models\Forms{
/**
*
*
* @property int $id
* @property int $form_id
* @property array $data
* @property string $date
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic query()
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereDate($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereId($value)
*/
class FormStatistic extends \Eloquent {}
}
namespace App\Models\Forms{
/**
*
*
* @property int $id
* @property int $form_id
* @property array $data
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission query()
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereUpdatedAt($value)
*/
class FormSubmission extends \Eloquent {}
}
namespace App\Models\Forms{
/**
*
*
* @property int $id
* @property int $form_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormView newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormView newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormView query()
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereUpdatedAt($value)
*/
class FormView extends \Eloquent {}
}
namespace App\Models\Integration{
/**
*
*
* @property int $id
* @property int $form_id
* @property string $status
* @property string $integration_id
* @property object $logic
* @property object $data
* @property string|null $oauth_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Integration\FormIntegrationsEvent> $events
* @property-read int|null $events_count
* @property-read \App\Models\Forms\Form|null $form
* @property-read \App\Models\OAuthProvider|null $provider
* @method static \Database\Factories\Integration\FormIntegrationFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration query()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereIntegrationId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereLogic($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereOauthId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereUpdatedAt($value)
*/
class FormIntegration extends \Eloquent {}
}
namespace App\Models\Integration{
/**
*
*
* @property int $id
* @property int $integration_id
* @property string $status
* @property object $data
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Integration\FormIntegration|null $integration
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent query()
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereIntegrationId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereUpdatedAt($value)
*/
class FormIntegrationsEvent extends \Eloquent {}
}
namespace App\Models\Integration{
/**
*
*
* @property int $id
* @property int $form_id
* @property string $hook_url
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook query()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereHookUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook withoutTrashed()
*/
class FormZapierWebhook extends \Eloquent {}
}
namespace App\Models{
/**
*
*
* @property int $id
* @property string $license_key
* @property int|null $user_id
* @property string $license_provider
* @property string $status
* @property array $meta
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read int|null $custom_domain_limit_count
* @property-read int $max_file_size
* @property-read int|null $max_users_count
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|License active()
* @method static \Illuminate\Database\Eloquent\Builder|License newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|License newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|License query()
* @method static \Illuminate\Database\Eloquent\Builder|License whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereLicenseKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereLicenseProvider($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereMeta($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereUserId($value)
*/
class License extends \Eloquent {}
}
namespace App\Models{
/**
*
*
* @property int $id
* @property int $user_id
* @property \App\Integrations\OAuth\OAuthProviderService $provider
* @property string $provider_user_id
* @property string|null $access_token
* @property string|null $refresh_token
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string|null $email
* @property string|null $name
* @property \Illuminate\Support\Carbon|null $token_expires_at
* @property-read \App\Models\User $user
* @method static \Database\Factories\OAuthProviderFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider query()
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereAccessToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProvider($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProviderUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereRefreshToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereTokenExpiresAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUserId($value)
*/
class OAuthProvider extends \Eloquent {}
}
namespace App\Models{
/**
*
*
* @property int $id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string $name
* @property string $slug
* @property string $description
* @property string|null $image_url
* @property array $structure
* @property array $questions
* @property bool $publicly_listed
* @property array $industries
* @property array $types
* @property string|null $short_description
* @property array $related_templates
* @property int|null $creator_id
* @property-read mixed $share_url
* @method static \Illuminate\Database\Eloquent\Builder|Template newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Template newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Template publiclyListed()
* @method static \Illuminate\Database\Eloquent\Builder|Template query()
* @method static \Illuminate\Database\Eloquent\Builder|Template whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereCreatorId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereImageUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereIndustries($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template wherePubliclyListed($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereQuestions($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereRelatedTemplates($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereShortDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereSlug($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereStructure($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereTypes($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereUpdatedAt($value)
*/
class Template extends \Eloquent {}
}
namespace App\Models{
/**
*
*
* @property int $id
* @property string $name
* @property string $email
* @property \Illuminate\Support\Carbon|null $email_verified_at
* @property string|null $password
* @property string|null $remember_token
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string|null $stripe_id
* @property string|null $pm_type
* @property string|null $pm_last_four
* @property string|null $trial_ends_at
* @property string|null $hear_about_us
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Template> $formTemplates
* @property-read int|null $form_templates_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\Form> $forms
* @property-read int|null $forms_count
* @property-read mixed $admin
* @property-read mixed $has_customer_id
* @property-read mixed $has_forms
* @property-read mixed $is_risky
* @property-read mixed $is_subscribed
* @property-read mixed $moderator
* @property-read string $photo_url
* @property-read mixed $template_editor
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\License> $licenses
* @property-read int|null $licenses_count
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\OAuthProvider> $oauthProviders
* @property-read int|null $oauth_providers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Billing\Subscription> $subscriptions
* @property-read int|null $subscriptions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Workspace> $workspaces
* @property-read int|null $workspaces_count
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|User hasExpiredGenericTrial()
* @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|User newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|User onGenericTrial()
* @method static \Illuminate\Database\Eloquent\Builder|User query()
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereHearAboutUs($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder|User wherePmLastFour($value)
* @method static \Illuminate\Database\Eloquent\Builder|User wherePmType($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereStripeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereTrialEndsAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User withActiveSubscription()
*/
class User extends \Eloquent implements \Tymon\JWTAuth\Contracts\JWTSubject {}
}
namespace App\Models{
/**
*
*
* @property int $id
* @property int $workspace_id
* @property string $email
* @property string $role
* @property string $token
* @property string $status
* @property string|null $valid_until
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Workspace|null $workspace
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite notExpired()
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite pending()
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite query()
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereRole($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereValidUntil($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereWorkspaceId($value)
*/
class UserInvite extends \Eloquent {}
}
namespace App\Models{
/**
*
*
* @property int $id
* @property int $workspace_id
* @property int $user_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string $role
* @property-read \App\Models\User $user
* @property-read \App\Models\Workspace $workspace
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace query()
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereRole($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereWorkspaceId($value)
*/
class UserWorkspace extends \Eloquent {}
}
namespace App\Models{
/**
*
*
* @property int $id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string $name
* @property string|null $icon
* @property array|null $custom_domains
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\Form> $forms
* @property-read int|null $forms_count
* @property-read mixed $custom_domain_count_limit
* @property-read mixed $is_enterprise
* @property-read mixed $is_pro
* @property-read mixed $is_risky
* @property-read mixed $is_trialing
* @property-read mixed $max_file_size
* @property-read mixed $max_user_count_limit
* @property-read mixed $submissions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserInvite> $invites
* @property-read int|null $invites_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $users
* @property-read int|null $users_count
* @method static \Database\Factories\WorkspaceFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|Workspace newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Workspace newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Workspace query()
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereCustomDomains($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereIcon($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereUpdatedAt($value)
*/
class Workspace extends \Eloquent implements \App\Models\Traits\CachableAttributes {}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Forms\FormStatistic;
use App\Models\Forms\FormView;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CleanDatabase extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'forms:database-cleanup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Database Cleanup';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->cleanFormStatistics();
$this->line('Database Cleanup Success.');
}
/**
* Manage FormViews & FormSubmissions records
*/
private function cleanFormStatistics()
{
$this->line('Aggregating form views...');
$now = now();
$finalData = [];
// Form Views
FormView::select('form_id', DB::raw('DATE(created_at) as date'), DB::raw('count(*) as views'))
->where('created_at', '<', $now)
->orderBy('date')
->groupBy('form_id', 'date')
->get()->each(function ($row) use (&$finalData) {
$finalData[$row->form_id.'-'.$row->date] = [
'form_id' => $row->form_id,
'date' => $row->date,
'data' => [
'views' => $row->views,
'submissions' => 0,
],
];
});
if ($finalData) {
$this->line('Storing aggregated data...');
$created = 0;
$updated = 0;
// Insert into Form Statistic
foreach ($finalData as $row) {
$found = FormStatistic::where([['form_id', $row['form_id']], ['date', $row['date']]])->first();
if ($found !== null) { // If found update
$newData = $found->data;
$newData['views'] = $newData['views'] + $row['data']['views'];
$newData['submissions'] = 0;
$found->update(['data' => $newData]);
$updated++;
} else { // Otherwise create new
FormStatistic::create($row);
$created++;
}
}
$this->line($created.' form statistics records created.');
$this->line($updated.' form statistics records updated.');
// Delete Form Views those are migrated
$formViewRemovedCount = FormView::where('created_at', '<', $now)->delete();
$this->line($formViewRemovedCount.' form views records deleted.');
} else {
$this->line('No aggregate to store.');
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Models\Integration\FormIntegrationsEvent;
use Illuminate\Console\Command;
class CleanIntegrationEvents extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'forms:integration-events-cleanup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete Old Integration Events';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$response = FormIntegrationsEvent::where('created_at', '<', now()->subDays(14))->delete();
$this->line($response . ' Events Deleted');
}
}

View File

@@ -0,0 +1,418 @@
<?php
namespace App\Console\Commands;
use App\Models\Template;
use App\Service\OpenAi\GptCompleter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class GenerateTemplate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ai:make-form-template {prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generates a new form template from a prompt';
public const MAX_RELATED_TEMPLATES = 8;
public const FORM_STRUCTURE_PROMPT = <<<'EOD'
You are an AI assistant for OpnForm, a form builder and your job is to build a form for our user.
Forms are represented as Json objects. Here's an example form:
```json
{
"title": "Contact Us",
"properties": [
{
"help": null,
"name": "What's your name?",
"type": "text",
"hidden": false,
"required": true,
"placeholder": "Steve Jobs"
},
{
"help": "We will never share your email with anyone else.",
"name": "Email",
"type": "email",
"hidden": false,
"required": true,
"placeholder": "steve@apple.com"
},
{
"help": null,
"name": "How would you rate your overall experience?",
"type": "select",
"hidden": false,
"select": {
"options": [
{"name": 1, "value": 1},
{"name": 2, "value": 2},
{"name": 3, "value": 3},
{"name": 4, "value": 4},
{"name": 5, "value": 5}
]
},
"prefill": 5,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "Subject",
"type": "text",
"hidden": false,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "How can we help?",
"type": "text",
"hidden": false,
"required": true,
"multi_lines": true,
"placeholder": null,
"generates_uuid": false,
"max_char_limit": "2000",
"hide_field_name": false,
"show_char_limit": false
},
{
"help": "Upload any relevant files here.",
"name": "Have any attachments?",
"type": "files",
"hidden": false,
"placeholder": null
}
],
"description": "<p>Looking for a real person to speak to?</p><p>We're here for you! Just drop in your queries below and we'll connect with you as soon as we can.</p>",
"re_fillable": false,
"use_captcha": false,
"redirect_url": null,
"submitted_text": "<p>Great, we've received your message. We'll get back to you as soon as we can :)</p>",
"uppercase_labels": false,
"submit_button_text": "Submit",
"re_fill_button_text": "Fill Again",
"color": "#64748b"
}
```
The form properties can only have one of the following types: 'text', 'number', 'rating', 'scale','slider', 'select', 'multi_select', 'date', 'files', 'checkbox', 'url', 'email', 'phone_number', 'signature'.
All form properties objects need to have the keys 'help', 'name', 'type', 'hidden', 'placeholder', 'prefill'.
The placeholder property is optional (can be "null") and is used to display a placeholder text in the input field.
The help property is optional (can be "null") and is used to display extra information about the field.
For the type "select" and "multi_select", the input object must have a key "select" (or "multi_select") that's mapped to an object like this one:
```json
{
"options": [
{"name": 1, "value": 1},
{"name": 2, "value": 2},
{"name": 3, "value": 3},
{"name": 4, "value": 4}
]
}
```
For "rating" you can set the field property "rating_max_value" to set the maximum value of the rating.
For "scale" you can set the field property "scale_min_value", "scale_max_value" and "scale_step_value" to set the minimum, maximum and step value of the scale.
For "slider" you can set the field property "slider_min_value", "slider_max_value" and "slider_step_value" to set the minimum, maximum and step value of the slider.
If the form is too long, you can paginate it by adding a page break block in the list of properties:
```json
{
"name":"Page Break",
"next_btn_text":"Next",
"previous_btn_text":"Previous",
"type":"nf-page-break",
}
```
If you need to add more context to the form, you can add text blocks:
```json
{
"name":"My Text",
"type":"nf-text",
"content": "<p>This is a text block.</p>"
}
```
Give me the valid JSON object only, representing the following form: "[REPLACE]"
Do not ask me for more information about required properties or types, only suggest me a form structure.
EOD;
public const FORM_DESCRIPTION_PROMPT = <<<'EOD'
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
Give me some valid html code (using only h2, p, ul, li html tags) for the following form template page: "[REPLACE]".
The html code should have the following structure:
- A paragraph explaining what the template is about
- A paragraph explaining why and when to use such a form
- A paragraph explaining who is the target audience and why it's a great idea to build this form
- A paragraph explaining that OpnForm is the best tool to build this form. They can duplicate this template in a few seconds, and integrate with many other tools through our webhook or zapier integration.
Each paragraph (except for the first one) MUST start with with a h2 tag containing a title for this paragraph.
EOD;
public const FORM_SHORT_DESCRIPTION_PROMPT = <<<'EOD'
I own a form builder online named OpnForm. It's free to use.
Give me a 1 sentence description for the following form template page: "[REPLACE]". It should be short and concise, but still explain what the form is about.
EOD;
public const FORM_INDUSTRY_PROMPT = <<<'EOD'
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
I am creating a form template: "[REPLACE]". You must assign the template to industries. Return a list of industries (minimum 1, maximum 3 but only if very relevant) and order them by relevance (most relevant first).
Here are the only industries you can choose from: [INDUSTRIES]
Do no make up any new type, only use the ones listed above.
Reply only with a valid JSON array, being an array of string. Order assigned industries from the most relevant to the less relevant.
Ex: { "industries": ["banking_forms","customer_service_forms"]}
EOD;
public const FORM_TYPES_PROMPT = <<<'EOD'
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
I am creating a form template: "[REPLACE]". You must assign the template to one or more types. Return a list of types (minimum 1, maximum 3 but only if very accurate) and order them by relevance (most relevant first).
Here are the only types you can choose from: [TYPES]
Do no make up any new type, only use the ones listed above.
Reply only with a valid JSON array, being an array of string. Order assigned types from the most relevant to the less relevant.
Ex: { "types": ["consent_forms","award_forms"]}
EOD;
public const FORM_QAS_PROMPT = <<<'EOD'
Now give me 4 to 6 question and answers to put on the form template page. The questions should be about the reasons for this template (when to use, why, target audience, goal etc.).
The questions should also explain why OpnForm is the best option to create this form (open-source, free to use, integrations etc).
Reply only with a valid JSON, being an array of object containing the keys "question" and "answer".
EOD;
public const FORM_TITLE_PROMPT = <<<'EOD'
Finally give me a title for the template. It must contain or end with "template". It should be short and to the point, without any quotes.
EOD;
public const FORM_IMG_KEYWORDS_PROMPT = <<<'EOD'
I want to add an image to illustrate this form template page. Give me a relevant search query for unsplash. Reply only with a valid JSON like this:
```json
{
"search_query": ""
}
```
EOD;
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
// Get form structure
$completer = (new GptCompleter(config('services.openai.api_key')))
->useStreaming()
->setSystemMessage('You are an assistant helping to generate forms.');
$completer->expectsJson()->completeChat([
['role' => 'user', 'content' => Str::of(self::FORM_STRUCTURE_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()],
]);
$formData = $completer->getArray();
$formData = self::cleanAiOutput($formData);
$completer->doesNotExpectJson();
$formDescriptionPrompt = Str::of(self::FORM_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString();
$formShortDescription = $completer->completeChat([
['role' => 'user', 'content' => Str::of(self::FORM_SHORT_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()],
])->getString();
// If description is between quotes, remove quotes
$formShortDescription = Str::of($formShortDescription)->replaceMatches('/^"(.*)"$/', '$1')->toString();
// Get industry & types
$completer->expectsJson();
$industry = $this->getIndustries($completer, $this->argument('prompt'));
$types = $this->getTypes($completer, $this->argument('prompt'));
// Get Related Templates
$relatedTemplates = $this->getRelatedTemplates($industry, $types);
// Now get description and QAs
$completer->doesNotExpectJson();
$formDescription = $completer->completeChat([
['role' => 'user', 'content' => $formDescriptionPrompt],
])->getHtml();
$completer->expectsJson();
$formCoverKeywords = $completer->completeChat([
['role' => 'user', 'content' => $formDescriptionPrompt],
['role' => 'assistant', 'content' => $formDescription],
['role' => 'user', 'content' => self::FORM_IMG_KEYWORDS_PROMPT],
])->getArray();
$imageUrl = $this->getImageCoverUrl($formCoverKeywords['search_query']);
$formQAs = $completer->completeChat([
['role' => 'user', 'content' => $formDescriptionPrompt],
['role' => 'assistant', 'content' => $formDescription],
['role' => 'user', 'content' => self::FORM_QAS_PROMPT],
])->getArray();
$completer->doesNotExpectJson();
$formTitle = $completer->completeChat([
['role' => 'user', 'content' => $formDescriptionPrompt],
['role' => 'assistant', 'content' => $formDescription],
['role' => 'user', 'content' => self::FORM_TITLE_PROMPT],
])->getString();
$template = $this->createFormTemplate(
$formData,
$formTitle,
$formDescription,
$formShortDescription,
$formQAs,
$imageUrl,
$industry,
$types,
$relatedTemplates
);
$this->info('/form-templates/' . $template->slug);
// Set reverse related Templates
$this->setReverseRelatedTemplates($template);
return Command::SUCCESS;
}
/**
* Get an image cover URL for the template using unsplash API
*/
private function getImageCoverUrl($searchQuery): ?string
{
$url = 'https://api.unsplash.com/search/photos?query=' . urlencode($searchQuery) . '&client_id=' . config('services.unsplash.access_key');
$response = Http::get($url)->json();
$photoIndex = rand(0, max(count($response['results']) - 1, 10));
if (isset($response['results'][$photoIndex]['urls']['regular'])) {
return Str::of($response['results'][$photoIndex]['urls']['regular'])->replace('w=1080', 'w=600')->toString();
}
return null;
}
private function getIndustries(GptCompleter $completer, string $formPrompt): array
{
$industriesString = Template::getAllIndustries()->pluck('slug')->join(', ');
return $completer->completeChat([
['role' => 'user', 'content' => Str::of(self::FORM_INDUSTRY_PROMPT)
->replace('[REPLACE]', $formPrompt)
->replace('[INDUSTRIES]', $industriesString)
->toString()],
])->getArray()['industries'];
}
private function getTypes(GptCompleter $completer, string $formPrompt): array
{
$typesString = Template::getAllTypes()->pluck('slug')->join(', ');
return $completer->completeChat([
['role' => 'user', 'content' => Str::of(self::FORM_TYPES_PROMPT)
->replace('[REPLACE]', $formPrompt)
->replace('[TYPES]', $typesString)
->toString()],
])->getArray()['types'];
}
private function getRelatedTemplates(array $industries, array $types): array
{
$templateScore = [];
Template::chunk(100, function ($otherTemplates) use ($industries, $types, &$templateScore) {
foreach ($otherTemplates as $otherTemplate) {
$industryOverlap = count(array_intersect($industries ?? [], $otherTemplate->industry ?? []));
$typeOverlap = count(array_intersect($types ?? [], $otherTemplate->types ?? []));
$score = $industryOverlap + $typeOverlap;
if ($score > 1) {
$templateScore[$otherTemplate->slug] = $score;
}
}
});
arsort($templateScore); // Sort by Score
return array_slice(array_keys($templateScore), 0, self::MAX_RELATED_TEMPLATES);
}
private function createFormTemplate(
array $formData,
string $formTitle,
string $formDescription,
string $formShortDescription,
array $formQAs,
?string $imageUrl,
array $industry,
array $types,
array $relatedTemplates
) {
return Template::create([
'name' => $formTitle,
'description' => $formDescription,
'short_description' => $formShortDescription,
'questions' => $formQAs,
'structure' => $formData,
'image_url' => $imageUrl,
'publicly_listed' => true,
'industries' => $industry,
'types' => $types,
'related_templates' => $relatedTemplates,
]);
}
private function setReverseRelatedTemplates(Template $newTemplate)
{
if (!$newTemplate || count($newTemplate->related_templates) === 0) {
return;
}
$templates = Template::whereIn('slug', $newTemplate->related_templates)->get();
foreach ($templates as $template) {
if (count($template->related_templates) < self::MAX_RELATED_TEMPLATES) {
$template->update(['related_templates' => array_merge($template->related_templates, [$newTemplate->slug])]);
}
}
}
public static function cleanAiOutput(array $formData): array
{
// Add property uuids, improve form with options
foreach ($formData['properties'] as &$property) {
$property['id'] = Str::uuid()->toString(); // Column ID
// Fix types
if ($property['type'] == 'rating') {
$property['rating_max_value'] = $property['rating_max_value'] ?? 5;
} elseif ($property['type'] == 'scale') {
$property['scale_min_value'] = $property['scale_min_value'] ?? 1;
$property['scale_max_value'] = $property['scale_max_value'] ?? 5;
$property['scale_step_value'] = $property['scale_step_value'] ?? 1;
} elseif ($property['type'] == 'slider') {
$property['slider_min_value'] = $property['slider_min_value'] ?? 0;
$property['slider_max_value'] = $property['slider_max_value'] ?? 100;
$property['slider_step_value'] = $property['slider_step_value'] ?? 1;
}
if (($property['type'] == 'select' && count($property['select']['options']) <= 4)
|| ($property['type'] == 'multi_select' && count($property['multi_select']['options']) <= 4)
) {
$property['without_dropdown'] = true;
}
}
// Clean data
$formData['title'] = Str::of($formData['title'])->replace('"', '')->toString();
return $formData;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class InitProjectCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:init-project';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Creates the default admin user';
/**
* Execute the console command.
*/
public function handle()
{
if (!config('app.self_hosted')) {
$this->error('This command can only be run in self-hosted mode.');
return;
}
// Check if there are any existing users or if the ID increment is not at 0
if (User::max('id') !== null) {
$this->error('Users already exist in the database or the User table is not empty. Aborting initialization.');
return;
}
User::create([
'name' => 'Admin',
'email' => 'admin@opnform.com',
'password' => bcrypt('password'),
]);
$this->info('Admin user created with default credentials: admin@opnform.com / password');
return 0;
}
}

View File

@@ -0,0 +1,257 @@
<?php
namespace App\Console\Commands\Tax;
use App\Exports\Tax\ArrayExport;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Stripe\Invoice;
class GenerateTaxExport extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'stripe:generate-stripe-export
{--start-date= : Start date (YYYY-MM-DD)}
{--end-date= : End date (YYYY-MM-DD)}
{--full-month : Use the full month of the start date}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Compute Stripe VAT per country';
public const EU_TAX_RATES = [
'AT' => 20,
'BE' => 21,
'BG' => 20,
'HR' => 25,
'CY' => 19,
'CZ' => 21,
'DK' => 25,
'EE' => 22,
'FI' => 24,
'FR' => 20,
'DE' => 19,
'GR' => 24,
'HU' => 27,
'IE' => 23,
'IT' => 22,
'LV' => 21,
'LT' => 21,
'LU' => 17,
'MT' => 18,
'NL' => 21,
'PL' => 23,
'PT' => 23,
'RO' => 19,
'SK' => 20,
'SI' => 22,
'ES' => 21,
'SE' => 25,
];
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
// iterate through all Stripe invoices
$startDate = $this->option('start-date');
$endDate = $this->option('end-date');
// Validate the date format
if ($startDate && ! Carbon::createFromFormat('Y-m-d', $startDate)) {
$this->error('Invalid start date format. Use YYYY-MM-DD.');
return Command::FAILURE;
}
if ($endDate && ! Carbon::createFromFormat('Y-m-d', $endDate)) {
$this->error('Invalid end date format. Use YYYY-MM-DD.');
return Command::FAILURE;
} elseif (! $endDate && $this->option('full-month')) {
$endDate = Carbon::parse($startDate)->endOfMonth()->endOfDay()->format('Y-m-d');
}
$this->info('Start date: '.$startDate);
$this->info('End date: '.$endDate);
$processedInvoices = [];
// Create a progress bar
$queryOptions = [
'limit' => 100,
'expand' => ['data.customer', 'data.customer.address', 'data.customer.tax_ids', 'data.payment_intent',
'data.payment_intent.payment_method', 'data.charge.balance_transaction'],
'status' => 'paid',
];
if ($startDate) {
$queryOptions['created']['gte'] = Carbon::parse($startDate)->startOfDay()->timestamp;
}
if ($endDate) {
$queryOptions['created']['lte'] = Carbon::parse($endDate)->endOfDay()->timestamp;
}
$invoices = Cashier::stripe()->invoices->all($queryOptions);
$bar = $this->output->createProgressBar();
$bar->start();
$paymentNotSuccessfulCount = 0;
$totalInvoice = 0;
do {
foreach ($invoices as $invoice) {
// Ignore if payment was refunded
if (($invoice->payment_intent->status ?? null) !== 'succeeded') {
$paymentNotSuccessfulCount++;
continue;
}
$processedInvoices[] = $this->formatInvoice($invoice);
$totalInvoice++;
// Advance the progress bar
$bar->advance();
}
$queryOptions['starting_after'] = end($invoices->data)->id;
sleep(5);
$invoices = $invoices->all($queryOptions);
} while ($invoices->has_more);
$bar->finish();
$this->line('');
$aggregatedReport = $this->aggregateReport($processedInvoices);
$filePath = 'opnform-tax-export-per-invoice_'.$startDate.'_'.$endDate.'.xlsx';
$this->exportAsXlsx($processedInvoices, $filePath);
$aggregatedReportFilePath = 'opnform-tax-export-aggregated_'.$startDate.'_'.$endDate.'.xlsx';
$this->exportAsXlsx($aggregatedReport, $aggregatedReportFilePath);
// Display the results
$this->info('Total invoices: '.$totalInvoice.' (with '.$paymentNotSuccessfulCount.' payment not successful or trial free invoice)');
return Command::SUCCESS;
}
private function aggregateReport($invoices): array
{
// Sum invoices per country
$aggregatedReport = [];
foreach ($invoices as $invoice) {
$country = $invoice['cust_country'];
$customerType = is_null($invoice['cust_vat_id']) && $this->isEuropeanCountry($country) ? 'individual' : 'business';
if (! isset($aggregatedReport[$country])) {
$defaultVal = [
'count' => 0,
'total_usd' => 0,
'tax_total_usd' => 0,
'total_after_tax_usd' => 0,
'total_eur' => 0,
'tax_total_eur' => 0,
'total_after_tax_eur' => 0,
];
$aggregatedReport[$country] = [
'individual' => $defaultVal,
'business' => $defaultVal,
];
}
$aggregatedReport[$country][$customerType]['count']++;
$aggregatedReport[$country][$customerType]['total_usd'] = ($aggregatedReport[$country][$customerType]['total_usd'] ?? 0) + $invoice['total_usd'];
$aggregatedReport[$country][$customerType]['tax_total_usd'] = ($aggregatedReport[$country][$customerType]['tax_total_usd'] ?? 0) + $invoice['tax_total_usd'];
$aggregatedReport[$country][$customerType]['total_after_tax_usd'] = ($aggregatedReport[$country][$customerType]['total_after_tax_usd'] ?? 0) + $invoice['total_after_tax_usd'];
$aggregatedReport[$country][$customerType]['total_eur'] = ($aggregatedReport[$country][$customerType]['total_eur'] ?? 0) + $invoice['total_eur'];
$aggregatedReport[$country][$customerType]['tax_total_eur'] = ($aggregatedReport[$country][$customerType]['tax_total_eur'] ?? 0) + $invoice['tax_total_eur'];
$aggregatedReport[$country][$customerType]['total_after_tax_eur'] = ($aggregatedReport[$country][$customerType]['total_after_tax_eur'] ?? 0) + $invoice['total_after_tax_eur'];
}
$finalReport = [];
foreach ($aggregatedReport as $country => $data) {
foreach ($data as $customerType => $aggData) {
$finalReport[] = [
'country' => $country,
'customer_type' => $customerType,
...$aggData,
];
}
}
return $finalReport;
}
private function formatInvoice(Invoice $invoice): array
{
$country = $invoice->customer->address->country ?? $invoice->payment_intent->payment_method->card->country ?? null;
$vatId = $invoice->customer->tax_ids->data[0]->value ?? null;
$taxRate = $this->computeTaxRate($country, $vatId);
$taxAmountCollectedUsd = $taxRate > 0 ? $invoice->total * $taxRate / ($taxRate + 100) : 0;
$totalEur = $invoice->charge->balance_transaction->amount;
$taxAmountCollectedEur = $taxRate > 0 ? $totalEur * $taxRate / ($taxRate + 100) : 0;
return [
'invoice_id' => $invoice->id,
'created_at' => Carbon::createFromTimestamp($invoice->created)->format('Y-m-d H:i:s'),
'cust_id' => $invoice->customer->id,
'cust_vat_id' => $vatId,
'cust_country' => $country,
'tax_rate' => $taxRate,
'total_usd' => $invoice->total / 100,
'tax_total_usd' => $taxAmountCollectedUsd / 100,
'total_after_tax_usd' => ($invoice->total - $taxAmountCollectedUsd) / 100,
'total_eur' => $totalEur / 100,
'tax_total_eur' => $taxAmountCollectedEur / 100,
'total_after_tax_eur' => ($totalEur - $taxAmountCollectedEur) / 100,
];
}
private function computeTaxRate($countryCode, $vatId)
{
// Since we're a French company, for France, always apply 20% VAT
if ($countryCode == 'FR' ||
is_null($countryCode) ||
empty($countryCode)) {
return self::EU_TAX_RATES['FR'];
}
if ($taxRate = (self::EU_TAX_RATES[$countryCode] ?? null)) {
// If VAT ID is provided, then TAX is 0%
if (! $vatId) {
return $taxRate;
}
}
return 0;
}
private function isEuropeanCountry($countryCode)
{
return isset(self::EU_TAX_RATES[$countryCode]);
}
private function exportAsXlsx($data, $filename)
{
if (count($data) == 0) {
$this->info('Empty data. No file generated.');
return;
}
(new ArrayExport($data))->store($filename, 'local', \Maatwebsite\Excel\Excel::XLSX);
$this->line('File generated: '.storage_path('app/'.$filename));
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
/**
* Define the application's command schedule.
*
* @return void
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('forms:database-cleanup')->hourly();
$schedule->command('forms:integration-events-cleanup')->daily();
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}
}

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

@@ -0,0 +1,26 @@
<?php
namespace App\Events\Billing;
use App\Models\Billing\Subscription;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionCreated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(public Subscription $subscription)
{
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Events\Billing;
use App\Models\Billing\Subscription;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionUpdated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subscription $subscription)
{
//
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Events\Forms;
use App\Models\Forms\Form;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormSubmitted
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public $form;
public $data;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(Form $form, array $data)
{
$this->form = $form;
$this->data = $data;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Events\Models;
use App\Models\Forms\Form;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormCreated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(public Form $form)
{
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Events\Models;
use App\Models\Integration\FormIntegration;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormIntegrationCreated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public FormIntegration $formIntegration
) {
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Events\Models;
use App\Models\Integration\FormIntegrationsEvent;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormIntegrationsEventCreated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(public FormIntegrationsEvent $formIntegrationsEvent)
{
//
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
class EmailTakenException extends Exception
{
/**
* Render the exception as an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function render($request)
{
return response()->view('oauth.emailTaken', [], 400);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Exceptions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Log;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
* @var array
*/
protected $dontReport = [
//
];
/**
* A list of the exception types that are not reported to Sentry.
*
* @var array
*/
protected $sentryDontReport = [
//
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array
*/
protected $dontFlash = [
'password',
'password_confirmation',
];
/**
* Convert an authentication exception into a response.
*/
protected function unauthenticated($request, AuthenticationException $exception)
{
return response()->json(['message' => $exception->getMessage()], 401);
}
public function report(Throwable $exception)
{
if ($this->shouldReport($exception)) {
if (app()->bound('sentry') && $this->sentryShouldReport($exception)) {
app('sentry')->captureException($exception);
Log::debug('Un-handled Exception: '.$exception->getMessage(), [
'exception' => $exception,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTrace(),
]);
}
}
parent::report($exception);
}
public function render($request, Throwable $e)
{
if ($this->shouldReport($e) && ! in_array(\App::environment(), ['testing']) && config('logging.channels.slack.enabled')) {
Log::channel('slack')->error($e);
}
return parent::render($request, $e);
}
private function sentryShouldReport(Throwable $e)
{
foreach ($this->sentryDontReport as $exceptionType) {
if ($e instanceof $exceptionType) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Illuminate\Validation\ValidationException;
class VerifyEmailException extends ValidationException
{
/**
* @param \App\User $user
* @return static
*/
public static function forUser($user)
{
return static::withMessages([
'email' => [__('You must :linkOpen verify :linkClose your email first.', [
'linkOpen' => '<a href="/email/resend?email='.urlencode($user->email).'">',
'linkClose' => '</a>',
])],
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Exceptions\Workspaces;
use App\Models\Workspace;
use Exception;
class WorkspaceAlreadyExisting extends Exception
{
public function __construct(public Workspace $workspace)
{
}
public function getErrorMessage()
{
$owner = $this->workspace->users()->first();
if (! $owner) {
return 'A user already connected that workspace to another NotionForms account. You or the current workspace
owner must have a NotionForms Enterprise subscription for you to add this Notion workspace. Please upgrade
with an Enterprise subscription, or contact us to get help.';
}
return '"'.$owner->name.'" already connected that workspace to his NotionForms account. In order to collaborate,
one of you must have a NotionForms Enterprise subscription. Please upgrade or contact us to get help.';
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Exceptions\Workspaces;
use Exception;
class WorkspaceLimit extends Exception
{
//
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
class FormSubmissionExport implements FromArray, WithHeadingRow
{
protected array $submissionData;
public function __construct(array $submissionData)
{
$headingRow = [];
$contentRow = [];
foreach ($submissionData as $i => $row) {
if ($i == 0) {
$headingRow[] = $this->cleanColumnNames(array_keys($row));
}
$contentRow[] = array_values($row);
}
$this->submissionData = [
$headingRow,
$contentRow,
];
}
private function cleanColumnNames(array $columnNames): array
{
return collect($columnNames)->map(function ($columnName) {
return preg_replace('/\s\(.*\)/', '', $columnName);
})->toArray();
}
public function array(): array
{
return $this->submissionData;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Exports\Tax;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
class ArrayExport implements FromArray, WithHeadings
{
use Exportable;
public function __construct(public array $data)
{
}
public function array(): array
{
return $this->data;
}
public function headings(): array
{
return array_keys($this->data[0]);
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Laravel\Cashier\Cashier;
class AdminController extends Controller
{
public const ADMIN_LOG_PREFIX = '[admin_action] ';
public function __construct()
{
$this->middleware('moderator');
}
public function fetchUser($identifier)
{
$user = null;
if (is_numeric($identifier)) {
$user = User::find($identifier);
} elseif (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
$user = User::whereEmail($identifier)->first();
} else {
// Find by form slug
$form = Form::whereSlug($identifier)->first();
if ($form) {
$user = $form->creator;
}
}
if (!$user) {
return $this->error([
'message' => 'User not found.'
]);
} elseif ($user->admin) {
return $this->error([
'message' => 'You cannot fetch an admin.'
]);
}
$workspaces = $user->workspaces()
->withCount('forms')
->get()
->map(function ($workspace) {
$plan = 'free';
if ($workspace->is_trialing) {
$plan = 'trialing';
}
if ($workspace->is_pro) {
$plan = 'pro';
}
if ($workspace->is_enterprise) {
$plan = 'enterprise';
}
return [
'id' => $workspace->id,
'name' => $workspace->name,
'plan' => $plan,
'forms_count' => $workspace->forms_count
];
});
return $this->success([
'user' => $user,
'workspaces' => $workspaces
]);
}
public function applyDiscount(Request $request)
{
$request->validate([
'user_id' => 'required'
]);
$user = User::find($request->get("user_id"));
$activeSubscriptions = $user->subscriptions()->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
})->get();
if ($activeSubscriptions->count() != 1) {
return $this->error([
"message" => "The user has more than one active subscriptions or doesn't have one."
]);
}
$couponId = config('pricing.discount_coupon_id');
if (is_null($couponId)) {
return $this->error([
"message" => "Coupon id not defined."
]);
}
$subscription = $activeSubscriptions->first();
Cashier::stripe()->subscriptions->update($subscription->stripe_id, [
'coupon' => $couponId
]);
self::log('Applying NGO/Student discount to sub', [
'user_id' => $user->id,
'subcription_id' => $subscription->id,
'coupon_id' => $couponId,
'subscription_stripe_id' => $subscription->stripe_id,
'moderator_id' => auth()->id(),
]);
return $this->success([
"message" => "40% Discount applied for the next 12 months."
]);
}
public function extendTrial(Request $request)
{
$request->validate([
'user_id' => 'required',
'number_of_day' => 'required|numeric|max:14'
]);
$user = User::find($request->get("user_id"));
$subscription = $user->subscriptions()
->where('stripe_status', 'trialing')
->firstOrFail();
$trialEndDate = now()->addDays($request->get('number_of_day'));
$subscription->extendTrial($trialEndDate);
self::log('Trial extended', [
'user_id' => $user->id,
'subcription_id' => $subscription->id,
'nb_days' => $request->get('number_of_day'),
'subscription_stripe_id' => $subscription->stripe_id,
'moderator_id' => auth()->id(),
]);
return $this->success([
"message" => "Subscription trial extend until the " . $trialEndDate->format('d/m/Y')
]);
}
public function cancelSubscription(Request $request)
{
$request->validate([
'user_id' => 'required',
'cancellation_reason' => 'required'
]);
$user = User::find($request->get("user_id"));
$activeSubscriptions = $user->subscriptions()->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
})->get();
if ($activeSubscriptions->count() != 1) {
return $this->error([
"message" => "The user has more than one active subscriptions or doesn't have one."
]);
}
$subscription = $activeSubscriptions->first();
$subscription->cancel();
self::log('Cancel Subscription', [
'user_id' => $user->id,
'cancel_reason' => $request->get('cancellation_reason'),
'moderator_id' => auth()->id(),
'subcription_id' => $subscription->id,
'subscription_stripe_id' => $subscription->stripe_id
]);
return $this->success([
"message" => "The subscription cancellation has been successfully completed."
]);
}
public function sendPasswordResetEmail(Request $request)
{
$user = User::findOrFail($request->user_id);
$status = Password::sendResetLink(['email' => $user->email]);
if ($status !== Password::RESET_LINK_SENT) {
return $this->error([
'message' => "Password reset email failed to send"
]);
}
self::log('Sent password reset email', [
'user_id' => $user->id,
'moderator_id' => auth()->id(),
]);
return $this->success([
'message' => "Password reset email has been sent to the user's email address"
]);
}
public static function log($message, $data = [])
{
\Log::warning(self::ADMIN_LOG_PREFIX . $message, $data);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
class BillingController extends Controller
{
public function __construct()
{
$this->middleware('moderator');
}
public function getEmail($userId)
{
$user = User::find($userId);
if (!$user->hasStripeId()) {
return $this->error([
"message" => "Stripe user not created",
]);
}
$user = $user->asStripeCustomer();
return $this->success([
'billing_email' => $user->email
]);
}
public function updateEmail(Request $request)
{
$request->validate([
'user_id' => 'required',
'billing_email' => 'required|email'
]);
$user = User::findOrFail($request->get("user_id"));
if (!$user->hasStripeId()) {
return $this->error([
"message" => "Stripe user not created",
]);
}
AdminController::log('Update billing email', [
'user_id' => $user->id,
'stripe_id' => $user->stripe_id,
'moderator_id' => auth()->id()
]);
$user->updateStripeCustomer(['email' => $request->billing_email]);
return $this->success(['message' => 'Billing email updated successfully']);
}
public function getSubscriptions($userId)
{
$user = User::find($userId);
if (!$user->hasStripeId()) {
return $this->error([
"message" => "Stripe user not created",
]);
}
$subscriptions = $user->subscriptions()->latest()->take(100)->get()->map(function ($subscription) use ($user) {
return [
"id" => $subscription->id,
"stripe_id" => $subscription->stripe_id,
"name" => ucfirst($user->name),
"plan" => $subscription->type,
"status" => $subscription->stripe_status,
"creation_date" => $subscription->created_at->format('Y-m-d')
];
});
return $this->success([
'subscriptions' => $subscriptions,
]);
}
public function getPayments($userId)
{
$user = User::find($userId);
if (!$user->hasStripeId()) {
return $this->error([
"message" => "Stripe user not created",
]);
}
$payments = $user->invoices();
$payments = $payments->map(function ($payment) use ($user) {
return [
"id" => $payment->id,
"amount_paid" => ($payment->amount_paid),
"name" => ucfirst($payment->account_name),
"creation_date" => Carbon::parse($payment->created)->format("Y-m-d H:i:s"),
"status" => $payment->status,
];
});
return $this->success([
'payments' => $payments,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use App\Models\User;
class FormController extends Controller
{
public function getDeletedForms($userId)
{
$user = User::find($userId);
$deletedForms = $user->forms()->with('creator')->onlyTrashed()->get()->map(function ($form) {
return [
"id" => $form->id,
"slug" => $form->slug,
"title" => $form->title,
"created_by" => $form->creator->email,
"deleted_at" => $form->deleted_at->format('Y-m-d'),
];
});
return $this->success(['forms' => $deletedForms]);
}
public function restoreDeletedForm(string $slug)
{
$form = Form::onlyTrashed()->whereSlug($slug)->firstOrFail();
$form->restore();
AdminController::log('Restore deleted form', [
'form_id' => $form->id,
'moderator_id' => auth()->id()
]);
return $this->success(['message' => 'Form restored successfully']);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
class ImpersonationController extends Controller
{
public function __construct()
{
$this->middleware('moderator');
}
public function impersonate($userId)
{
$user = User::find($userId);
if (!$user) {
return $this->error([
'message' => 'User not found.',
]);
} elseif ($user->admin) {
return $this->error([
'message' => 'You cannot impersonate an admin.',
]);
}
AdminController::log('Impersonation started', [
'from_id' => auth()->id(),
'from_email' => auth()->user()->email,
'target_id' => $user->id,
'target_email' => $user->id,
]);
$token = auth()->claims(
auth()->user()->admin ? [] : [
'impersonating' => true,
'impersonator_id' => auth()->id(),
]
)->login($user);
return $this->success([
'token' => $token,
]);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
class AppSumoAuthController extends Controller
{
use AuthenticatesUsers;
public function handleCallback(Request $request)
{
if (! $code = $request->code) {
return response()->json(['message' => 'Healthy'], 200);
}
$accessToken = $this->retrieveAccessToken($code);
$license = $this->fetchOrCreateLicense($accessToken);
// If user connected, attach license
if (Auth::check()) {
return $this->attachLicense($license);
}
// otherwise start login flow by passing the encrypted license key id
if (is_null($license->user_id)) {
return redirect(front_url('/register?appsumo_license='.encrypt($license->id)));
}
return redirect(front_url('/register?appsumo_error=1'));
}
private function retrieveAccessToken(string $requestCode): string
{
return Http::withHeaders([
'Content-type' => 'application/json',
])->post('https://appsumo.com/openid/token/', [
'grant_type' => 'authorization_code',
'code' => $requestCode,
'redirect_uri' => route('appsumo.callback'),
'client_id' => config('services.appsumo.client_id'),
'client_secret' => config('services.appsumo.client_secret'),
])->throw()->json('access_token');
}
private function fetchOrCreateLicense(string $accessToken): License
{
// Fetch license from API
$licenseKey = Http::get('https://appsumo.com/openid/license_key/?access_token='.$accessToken)
->throw()
->json('license_key');
// Fetch or create license model
$license = License::where('license_provider', 'appsumo')->where('license_key', $licenseKey)->first();
if (! $license) {
$licenseData = Http::withHeaders([
'X-AppSumo-Licensing-Key' => config('services.appsumo.api_key'),
])->get('https://api.licensing.appsumo.com/v2/licenses/'.$licenseKey)->json();
// Create new license
$license = License::create([
'license_key' => $licenseKey,
'license_provider' => 'appsumo',
'status' => $licenseData['status'] === 'active' ? License::STATUS_ACTIVE : License::STATUS_INACTIVE,
'meta' => $licenseData,
]);
}
return $license;
}
private function attachLicense(License $license)
{
if (! Auth::check()) {
throw new AuthenticationException('User not authenticated');
}
// Attach license if not already attached
if (is_null($license->user_id)) {
$license->user_id = Auth::id();
$license->save();
return redirect(front_url('/home?appsumo_connect=1'));
}
// Licensed already attached
return redirect(front_url('/home?appsumo_error=1'));
}
/**
* @return string|null
*
* Returns null if no license found
* Returns true if license was found and attached
* Returns false if there was an error (license not found or already attached)
*/
public static function registerWithLicense(User $user, ?string $licenseHash): ?bool
{
if (! $licenseHash) {
return null;
}
$licenseId = decrypt($licenseHash);
$license = License::find($licenseId);
if ($license && is_null($license->user_id)) {
$license->user_id = $user->id;
$license->save();
return true;
}
return false;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
class ForgotPasswordController extends Controller
{
use SendsPasswordResetEmails;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Get the response for a successful password reset link.
*
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetLinkResponse(Request $request, $response)
{
return ['status' => trans($response)];
}
/**
* Get the response for a failed password reset link.
*
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetLinkFailedResponse(Request $request, $response)
{
return response()->json(['email' => trans($response)], 400);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Exceptions\VerifyEmailException;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
use AuthenticatesUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest')->except('logout');
}
/**
* Attempt to log the user into the application.
*
* @return bool
*/
protected function attemptLogin(Request $request)
{
$token = $this->guard()->attempt($this->credentials($request));
if (! $token) {
return false;
}
$user = $this->guard()->user();
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
return false;
}
$this->guard()->setToken($token);
return true;
}
/**
* Get the needed authorization credentials from the request.
*
* @return array
*/
protected function credentials(Request $request)
{
return [
$this->username() => strtolower($request->get($this->username())),
'password' => $request->password,
];
}
/**
* Send the response after the user was authenticated.
*
* @return \Illuminate\Http\JsonResponse
*/
protected function sendLoginResponse(Request $request)
{
$this->clearLoginAttempts($request);
$token = (string) $this->guard()->getToken();
$expiration = $this->guard()->getPayload()->get('exp');
return response()->json([
'token' => $token,
'token_type' => 'bearer',
'expires_in' => $expiration - time(),
]);
}
/**
* Get the failed login response instance.
*
* @return \Illuminate\Http\JsonResponse
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function sendFailedLoginResponse(Request $request)
{
$user = $this->guard()->user();
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
throw VerifyEmailException::forUser($user);
}
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
]);
}
/**
* Log the user out of the application.
*
* @return \Illuminate\Http\Response
*/
public function logout(Request $request)
{
$this->guard()->logout();
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Integrations\OAuth\OAuthProviderService;
use App\Models\OAuthProvider;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
class OAuthController extends Controller
{
use AuthenticatesUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
config([
'services.github.redirect' => route('oauth.callback', 'github'),
]);
}
/**
* Redirect the user to the provider authentication page.
*
* @param string $provider
* @return \Illuminate\Http\RedirectResponse
*/
public function redirect(OAuthProviderService $provider)
{
return response()->json([
'url' => $provider->getDriver()->setRedirectUrl(config('services.google.auth_redirect'))->getRedirectUrl()
]);
}
/**
* Obtain the user information from the provider.
*
* @param string $driver
* @return \Illuminate\Http\Response
*/
public function handleCallback(OAuthProviderService $provider)
{
try {
$driverUser = $provider->getDriver()->setRedirectUrl(config('services.google.auth_redirect'))->getUser();
} catch (\Exception $e) {
return $this->error([
"message" => "OAuth service failed to authenticate: " . $e->getMessage()
]);
}
$user = $this->findOrCreateUser($provider, $driverUser);
if (!$user) {
return $this->error([
"message" => "User not found."
]);
}
if ($user->has_registered) {
return $this->error([
"message" => "This email is already registered. Please sign in with your password."
]);
}
$this->guard()->setToken(
$token = $this->guard()->login($user)
);
return response()->json([
'token' => $token,
'token_type' => 'bearer',
'expires_in' => $this->guard()->getPayload()->get('exp') - time(),
'new_user' => $user->new_user
]);
}
/**
* @p aram \Laravel\Socialite\Contracts\User $socialiteUser
* @return \App\Models\User | null
*/
protected function findOrCreateUser($provider, $socialiteUser)
{
$oauthProvider = OAuthProvider::where('provider', $provider)
->where('provider_user_id', $socialiteUser->getId())
->first();
if ($oauthProvider) {
$oauthProvider->update([
'access_token' => $socialiteUser->token,
'refresh_token' => $socialiteUser->refreshToken,
]);
return $oauthProvider->user;
}
if (!$provider->getDriver()->canCreateUser()) {
return null;
}
$email = strtolower($socialiteUser->getEmail());
$user = User::whereEmail($email)->first();
if ($user) {
$user->has_registered = true;
return $user;
}
$user = User::create([
'name' => $socialiteUser->getName(),
'email' => $email,
'email_verified_at' => now(),
]);
// Create and sync workspace
$workspace = Workspace::create([
'name' => 'My Workspace',
'icon' => '🧪',
]);
$user->workspaces()->sync([
$workspace->id => [
'role' => User::ROLE_ADMIN,
],
], false);
$user->new_user = true;
OAuthProvider::create(
[
'user_id' => $user->id,
'provider' => $provider,
'provider_user_id' => $socialiteUser->getId(),
'access_token' => $socialiteUser->token,
'refresh_token' => $socialiteUser->refreshToken,
'name' => $socialiteUser->getName(),
'email' => $socialiteUser->getEmail(),
]
);
return $user;
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Models\UserInvite;
use App\Models\Workspace;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class RegisterController extends Controller
{
use RegistersUsers;
private ?bool $appsumoLicense = null;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* The user has been registered.
*
* @param \App\User $user
* @return \Illuminate\Http\JsonResponse
*/
protected function registered(Request $request, User $user)
{
if ($user instanceof MustVerifyEmail) {
return response()->json(['status' => trans('verification.sent')]);
}
return response()->json(array_merge(
(new UserResource($user))->toArray($request),
[
'appsumo_license' => $this->appsumoLicense,
]
));
}
/**
* Get a validator for an incoming registration request.
*
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => 'required|max:255',
'email' => 'required|email:filter|max:255|unique:users|indisposable',
'password' => 'required|min:6|confirmed',
'hear_about_us' => 'required|string',
'agree_terms' => ['required', Rule::in([true])],
'appsumo_license' => ['nullable'],
'invite_token' => ['nullable', 'string'],
], [
'agree_terms' => 'Please agree with the terms and conditions.',
]);
}
/**
* Create a new user instance after a valid registration.
*/
protected function create(array $data)
{
$this->checkRegistrationAllowed($data);
[$workspace, $role] = $this->getWorkspaceAndRole($data);
$user = User::create([
'name' => $data['name'],
'email' => strtolower($data['email']),
'password' => bcrypt($data['password']),
'hear_about_us' => $data['hear_about_us'],
]);
// Add relation with user
$user->workspaces()->sync([
$workspace->id => [
'role' => $role,
],
], false);
$this->appsumoLicense = AppSumoAuthController::registerWithLicense($user, $data['appsumo_license'] ?? null);
return $user;
}
private function checkRegistrationAllowed(array $data)
{
if (config('app.self_hosted') && !array_key_exists('invite_token', $data) && (app()->environment() !== 'testing')) {
response()->json(['message' => 'Registration is not allowed in self host mode'], 400)->throwResponse();
}
}
private function getWorkspaceAndRole(array $data)
{
if (!array_key_exists('invite_token', $data)) {
return [
Workspace::create([
'name' => 'My Workspace',
'icon' => '🧪',
]),
User::ROLE_ADMIN
];
}
$userInvite = UserInvite::where('email', $data['email'])
->where('token', $data['invite_token'])
->first();
if (!$userInvite) {
response()->json(['message' => 'Invite token is invalid.'], 400)->throwResponse();
}
if ($userInvite->hasExpired()) {
response()->json(['message' => 'Invite token has expired.'], 400)->throwResponse();
}
if ($userInvite->status == UserInvite::ACCEPTED_STATUS) {
response()->json(['message' => 'Invite is already accepted.'], 400)->throwResponse();
}
$userInvite->markAsAccepted();
return [
$userInvite->workspace,
$userInvite->role,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
class ResetPasswordController extends Controller
{
use ResetsPasswords;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Get the response for a successful password reset.
*
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetResponse(Request $request, $response)
{
return ['status' => trans($response)];
}
/**
* Get the response for a failed password reset.
*
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetFailedResponse(Request $request, $response)
{
return response()->json(['email' => trans($response)], 400);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
/**
* Get authenticated user.
*/
public function current(Request $request)
{
return new UserResource($request->user());
}
public function deleteAccount()
{
$this->middleware('auth');
if (Auth::user()->admin) {
return $this->error([
'message' => 'Cannot delete an admin. Stay with us 🙏',
]);
}
Auth::user()->delete();
return $this->success([
'message' => 'User deleted.',
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\ValidationException;
class VerificationController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('throttle:6,1')->only('verify', 'resend');
}
/**
* Mark the user's email address as verified.
*
* @param \App\User $user
* @return \Illuminate\Http\JsonResponse
*/
public function verify(Request $request, User $user)
{
if (! URL::hasValidSignature($request)) {
return response()->json([
'status' => trans('verification.invalid'),
], 400);
}
if ($user->hasVerifiedEmail()) {
return response()->json([
'status' => trans('verification.already_verified'),
], 400);
}
$user->markEmailAsVerified();
event(new Verified($user));
return response()->json([
'status' => trans('verification.verified'),
]);
}
/**
* Resend the email verification notification.
*
* @return \Illuminate\Http\JsonResponse
*/
public function resend(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
$user = User::where('email', $request->email)->first();
if (is_null($user)) {
throw ValidationException::withMessages([
'email' => [trans('verification.user')],
]);
}
if ($user->hasVerifiedEmail()) {
throw ValidationException::withMessages([
'email' => [trans('verification.already_verified')],
]);
}
$user->sendEmailVerificationNotification();
return response()->json(['status' => trans('verification.sent')]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Workspace\CustomDomainRequest;
use App\Models\Workspace;
use Illuminate\Http\Request;
class CaddyController extends Controller
{
public function ask(Request $request)
{
$request->validate([
'domain' => 'required|string',
]);
// make sure domain is valid
$domain = $request->input('domain');
if (! preg_match(CustomDomainRequest::CUSTOM_DOMAINS_REGEX, $domain)) {
return $this->error([
'success' => false,
'message' => 'Invalid domain',
]);
}
\Log::info('Caddy request received', [
'domain' => $domain,
]);
if ($workspace = Workspace::whereJsonContains('custom_domains', $domain)->first()) {
\Log::info('Caddy request successful', [
'domain' => $domain,
'workspace' => $workspace->id,
]);
return $this->success([
'success' => true,
'message' => 'OK',
]);
}
\Log::info('Caddy request failed', [
'domain' => $domain,
'workspace' => $workspace?->id,
]);
return $this->error([
'success' => false,
'message' => 'Unauthorized domain',
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Content;
use App\Http\Controllers\Controller;
class ChangelogController extends Controller
{
public const CANNY_ENDPOINT = 'https://canny.io/api/v1/';
public function index()
{
return \Cache::remember('changelog_entries', now()->addHour(), function () {
$response = \Http::post(self::CANNY_ENDPOINT.'entries/list', [
'apiKey' => config('services.canny.api_key'),
'limit' => 3,
]);
return $response->json('entries');
});
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Content;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Forms\PublicFormController;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class FileUploadController extends Controller
{
/**
* Upload file to local temp
*
* @return \Illuminate\Http\JsonResponse
*/
public function upload(Request $request)
{
$request->validate(['file' => 'required|file']);
$uuid = (string) Str::uuid();
$path = $request->file('file')->storeAs(PublicFormController::TMP_FILE_UPLOAD_PATH, $uuid);
return response()->json([
'uuid' => $uuid,
'key' => $path,
], 201);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Content;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Laravel\Vapor\Http\Controllers\SignedStorageUrlController as Controller;
class SignedStorageUrlController extends Controller
{
/**
* Create a new signed URL.
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
$this->ensureEnvironmentVariablesAreAvailable($request);
$bucket = $request->input('bucket') ?: $_ENV['AWS_BUCKET'];
$client = $this->storageClient();
$uuid = (string) Str::uuid();
$expiresAfter = config('vapor.signed_storage_url_expires_after', 5);
$signedRequest = $client->createPresignedRequest(
$this->createCommand($request, $client, $bucket, $key = ('tmp/'.$uuid)),
sprintf('+%s minutes', $expiresAfter)
);
$uri = $signedRequest->getUri();
return response()->json([
'uuid' => $uuid,
'bucket' => $bucket,
'key' => $key,
'url' => $uri->getScheme().'://'.$uri->getAuthority().$uri->getPath().'?'.$uri->getQuery(),
'headers' => $this->headers($request, $signedRequest),
], 201);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests;
use DispatchesJobs;
use ValidatesRequests;
public function success($data = [])
{
return response()->json(array_merge([
'type' => 'success',
], $data));
}
public function error($data = [], $statusCode = 400)
{
return response()->json(array_merge([
'type' => 'error',
], $data), $statusCode);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class FontsController extends Controller
{
public function index(Request $request)
{
return \Cache::remember('google_fonts', 60 * 60, function () {
$url = "https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=" . config('services.google.fonts_api_key');
$response = Http::get($url);
if ($response->successful()) {
$fonts = collect($response->json()['items'])->filter(function ($font) {
return !in_array($font['category'], ['monospace']);
})->map(function ($font) {
return $font['family'];
})->toArray();
return response()->json($fonts);
}
return [];
});
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Requests\AiGenerateFormRequest;
use App\Models\Forms\AI\AiFormCompletion;
class AiFormController extends Controller
{
public function generateForm(AiGenerateFormRequest $request)
{
$this->middleware('throttle:4,1');
return $this->success([
'message' => 'We\'re working on your form, please wait ~1 min.',
'ai_form_completion_id' => AiFormCompletion::create([
'form_prompt' => $request->input('form_prompt'),
'ip' => $request->ip(),
])->id,
]);
}
public function show(AiFormCompletion $aiFormCompletion)
{
if ($aiFormCompletion->ip != request()->ip()) {
return $this->error('You are not authorized to view this AI completion.', 403);
}
return $this->success([
'ai_form_completion' => $aiFormCompletion,
]);
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreFormRequest;
use App\Http\Requests\UpdateFormRequest;
use App\Http\Requests\UploadAssetRequest;
use App\Http\Resources\FormResource;
use App\Models\Forms\Form;
use App\Models\Workspace;
use App\Service\Forms\FormCleaner;
use App\Service\Storage\StorageFileNameParser;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FormController extends Controller
{
public const ASSETS_UPLOAD_PATH = 'assets/forms';
private FormCleaner $formCleaner;
public function __construct()
{
$this->middleware('auth', ['except' => ['uploadAsset']]);
$this->formCleaner = new FormCleaner();
}
public function index($workspaceId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('view', $workspace);
$this->authorize('viewAny', Form::class);
$workspaceIsPro = $workspace->is_pro;
$forms = $workspace->forms()
->orderByDesc('updated_at')
->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro) {
// Add attributes for faster loading
$form->extra = (object) [
'loadedWorkspace' => $workspace,
'workspaceIsPro' => $workspaceIsPro,
'userIsOwner' => true,
'cleanings' => $this->formCleaner
->processForm(request(), $form)
->simulateCleaning($workspace)
->getPerformedCleanings(),
];
return $form;
});
return FormResource::collection($forms);
}
public function show($slug)
{
$form = Form::whereSlug($slug)->firstOrFail();
$this->authorize('view', $form);
// Add attributes for faster loading
$workspace = $form->workspace;
$form->extra = (object)[
'loadedWorkspace' => $workspace,
'workspaceIsPro' => $workspace->is_pro,
'userIsOwner' => true,
'cleanings' => $this->formCleaner
->processForm(request(), $form)
->simulateCleaning($workspace)
->getPerformedCleanings(),
];
return new FormResource($form);
}
/**
* Return all user forms, used for zapier
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function indexAll()
{
$forms = collect();
foreach (Auth::user()->workspaces as $workspace) {
$this->authorize('view', $workspace);
$this->authorize('viewAny', Form::class);
$workspaceIsPro = $workspace->is_pro;
$newForms = $workspace->forms()->get()->map(function (Form $form) use ($workspace, $workspaceIsPro) {
// Add attributes for faster loading
$form->extra = (object) [
'loadedWorkspace' => $workspace,
'workspaceIsPro' => $workspaceIsPro,
'userIsOwner' => true,
];
return $form;
});
$forms = $forms->merge($newForms);
}
return FormResource::collection($forms);
}
public function store(StoreFormRequest $request)
{
$this->authorize('create', Form::class);
$workspace = Workspace::findOrFail($request->get('workspace_id'));
$this->authorize('view', $workspace);
$formData = $this->formCleaner
->processRequest($request)
->simulateCleaning($workspace)
->getData();
$form = Form::create(array_merge($formData, [
'creator_id' => $request->user()->id,
]));
if ($this->formCleaner->hasCleaned()) {
$formStatus = $form->workspace->is_trialing ? 'Non-trial' : 'Pro';
$message = 'Form successfully created, but the ' . $formStatus . ' features you used will be disabled when sharing your form:';
} else {
$message = 'Form created.';
}
return $this->success([
'message' => $message . ($form->visibility == 'draft' ? ' But other people won\'t be able to see the form since it\'s currently in draft mode' : ''),
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
'users_first_form' => $request->user()->forms()->count() == 1,
]);
}
public function update(UpdateFormRequest $request, string $id)
{
$form = Form::findOrFail($id);
$this->authorize('update', $form);
$formData = $this->formCleaner
->processRequest($request)
->simulateCleaning($form->workspace)
->getData();
// Set Removed Properties
$formData['removed_properties'] = array_merge($form->removed_properties, collect($form->properties)->filter(function ($field) use ($formData) {
return !Str::of($field['type'])->startsWith('nf-') && !in_array($field['id'], collect($formData['properties'])->pluck('id')->toArray());
})->toArray());
$form->update($formData);
if ($this->formCleaner->hasCleaned()) {
$formSubscription = $form->is_pro ? 'Enterprise' : 'Pro';
$formStatus = $form->workspace->is_trialing ? 'Non-trial' : $formSubscription;
$message = 'Form successfully updated, but the ' . $formStatus . ' features you used will be disabled when sharing your form.';
} else {
$message = 'Form updated.';
}
return $this->success([
'message' => $message . ($form->visibility == 'draft' ? ' But other people won\'t be able to see the form since it\'s currently in draft mode' : ''),
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
]);
}
public function destroy($id)
{
$form = Form::findOrFail($id);
$this->authorize('delete', $form);
$form->delete();
return $this->success([
'message' => 'Form was deleted.',
]);
}
public function duplicate($id)
{
$form = Form::findOrFail($id);
$this->authorize('update', $form);
// Create copy
$formCopy = $form->replicate();
$formCopy->title = 'Copy of ' . $formCopy->title;
$formCopy->save();
return $this->success([
'message' => 'Form successfully duplicated. You are now editing the duplicated version of the form.',
'new_form' => new FormResource($formCopy),
]);
}
public function regenerateLink($id, $option)
{
$form = Form::findOrFail($id);
$this->authorize('update', $form);
if ($option == 'slug') {
$form->generateSlug();
} elseif ($option == 'uuid') {
$form->slug = Str::uuid();
}
$form->save();
return $this->success([
'message' => 'Form url successfully updated. Your new form url now is: ' . $form->share_url . '.',
'form' => new FormResource($form),
]);
}
/**
* Upload a form asset
*/
public function uploadAsset(UploadAssetRequest $request)
{
$fileNameParser = StorageFileNameParser::parse($request->url);
// Make sure we retrieve the file in tmp storage, move it to persistent
$fileName = PublicFormController::TMP_FILE_UPLOAD_PATH . '/' . $fileNameParser->uuid;
if (!Storage::exists($fileName)) {
// File not found, we skip
return null;
}
$newPath = self::ASSETS_UPLOAD_PATH . '/' . $fileNameParser->getMovedFileName();
Storage::move($fileName, $newPath);
return $this->success([
'message' => 'File uploaded.',
'url' => route('forms.assets.show', [$fileNameParser->getMovedFileName()]),
]);
}
/**
* File uploads retrieval
*/
public function viewFile($id, $fileName)
{
$form = Form::findOrFail($id);
$this->authorize('view', $form);
$path = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $form->id) . '/' . $fileName;
if (!Storage::exists($path)) {
return $this->error([
'message' => 'File not found.',
]);
}
return redirect()->to(Storage::temporaryUrl($path, now()->addMinutes(5)));
}
/**
* Updates a form's workspace
*/
public function updateWorkspace($id, $workspace_id)
{
$form = Form::findOrFail($id);
$workspace = Workspace::findOrFail($workspace_id);
$this->authorize('update', $form);
$this->authorize('view', $workspace);
$form->workspace_id = $workspace_id;
$form->creator_id = auth()->user()->id;
$form->save();
return $this->success([
'message' => 'Form workspace updated successfully.',
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use Carbon\CarbonPeriod;
class FormStatsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function getFormStats(string $workspaceId, string $formId)
{
$form = Form::findOrFail($formId);
$this->authorize('view', $form);
$formStats = $form->statistics()->where('date', '>', now()->subDays(29)->startOfDay())->get();
$periodStats = ['views' => [], 'submissions' => []];
foreach (CarbonPeriod::create(now()->subDays(29), now()) as $dateObj) {
$date = $dateObj->format('d-m-Y');
$statisticData = $formStats->where('date', $dateObj->format('Y-m-d'))->first();
$periodStats['views'][$date] = $statisticData->data['views'] ?? 0;
$periodStats['submissions'][$date] = $form->submissions()->whereDate('created_at', $dateObj)->count();
if ($dateObj->toDateString() === now()->toDateString()) {
$periodStats['views'][$date] += $form->views()->count();
}
}
return $periodStats;
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Exports\FormSubmissionExport;
use App\Http\Controllers\Controller;
use App\Http\Requests\AnswerFormRequest;
use App\Http\Resources\FormSubmissionResource;
use App\Jobs\Form\StoreFormSubmissionJob;
use App\Models\Forms\Form;
use App\Models\Forms\FormSubmission;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
use Vinkla\Hashids\Facades\Hashids;
class FormSubmissionController extends Controller
{
public function __construct()
{
$this->middleware('auth', ['except' => ['submissionFile']]);
$this->middleware('signed', ['only' => ['submissionFile']]);
}
public function submissions(string $id)
{
$form = Form::findOrFail((int) $id);
$this->authorize('view', $form);
return FormSubmissionResource::collection($form->submissions()->paginate(100));
}
public function update(AnswerFormRequest $request, $id, $submissionId)
{
$form = $request->form;
$this->authorize('update', $form);
$job = new StoreFormSubmissionJob($request->form, $request->validated());
$job->setSubmissionId($submissionId)->handle();
$data = new FormSubmissionResource(FormSubmission::findOrFail($submissionId));
return $this->success([
'message' => 'Record successfully updated.',
'data' => $data,
]);
}
public function export(string $id)
{
$form = Form::findOrFail((int) $id);
$this->authorize('view', $form);
$allRows = [];
foreach ($form->submissions->toArray() as $row) {
$formatter = (new FormSubmissionFormatter($form, $row['data']))
->outputStringsOnly()
->setEmptyForNoValue()
->showRemovedFields()
->showHiddenFields()
->useSignedUrlForFiles();
$allRows[] = [
'id' => Hashids::encode($row['id']),
'created_at' => date('Y-m-d H:i', strtotime($row['created_at'])),
...$formatter->getCleanKeyValue(),
];
}
$csvExport = (new FormSubmissionExport($allRows));
return Excel::download(
$csvExport,
$form->slug.'-submission-data.csv',
\Maatwebsite\Excel\Excel::CSV
);
}
public function submissionFile($id, $fileName)
{
$fileName = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $id).'/'
.urldecode($fileName);
if (! Storage::exists($fileName)) {
return $this->error([
'message' => 'File not found.',
], 404);
}
if (config('filesystems.default') !== 's3') {
return response()->file(Storage::path($fileName));
}
return redirect(
Storage::temporaryUrl($fileName, now()->addMinute())
);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Forms\Integration;
use App\Http\Controllers\Controller;
use App\Http\Requests\Integration\FormIntegrationsRequest;
use App\Http\Resources\FormIntegrationResource;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegration;
class FormIntegrationsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(string $id)
{
$form = Form::findOrFail((int)$id);
$this->authorize('view', $form);
$integrations = FormIntegration::query()
->where('form_id', $form->id)
->with('provider.user')
->get();
return FormIntegrationResource::collection($integrations);
}
public function create(FormIntegrationsRequest $request, string $id)
{
$form = Form::findOrFail((int)$id);
$this->authorize('update', $form);
/** @var FormIntegration $formIntegration */
$formIntegration = FormIntegration::create(
array_merge([
'form_id' => $form->id,
], $request->toIntegrationData())
);
$formIntegration->refresh();
$formIntegration->load('provider.user');
return $this->success([
'message' => 'Form Integration was created.',
'form_integration' => FormIntegrationResource::make($formIntegration)
]);
}
public function update(FormIntegrationsRequest $request, string $id, string $integrationid)
{
$form = Form::findOrFail((int)$id);
$this->authorize('update', $form);
$formIntegration = FormIntegration::findOrFail((int)$integrationid);
$formIntegration->update($request->toIntegrationData());
$formIntegration->load('provider.user');
return $this->success([
'message' => 'Form Integration was updated.',
'form_integration' => FormIntegrationResource::make($formIntegration)
]);
}
public function destroy(string $id, string $integrationid)
{
$form = Form::findOrFail((int)$id);
$this->authorize('update', $form);
$formIntegration = FormIntegration::findOrFail((int)$integrationid);
$formIntegration->delete();
return $this->success([
'message' => 'Form Integration was deleted.'
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Forms\Integration;
use App\Http\Controllers\Controller;
use App\Http\Resources\FormIntegrationsEventResource;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegrationsEvent;
class FormIntegrationsEventController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(string $id, string $integrationid)
{
$form = Form::findOrFail((int)$id);
$this->authorize('view', $form);
return FormIntegrationsEventResource::collection(
FormIntegrationsEvent::where('integration_id', (int)$integrationid)->orderByDesc('created_at')->get()
);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Forms\Integration;
use App\Http\Controllers\Controller;
use App\Http\Requests\Integration\StoreFormZapierWebhookRequest;
use App\Models\Integration\FormZapierWebhook;
class FormZapierWebhookController extends Controller
{
/**
* Controller for Zappier webhook subscriptions.
*/
public function __construct()
{
$this->middleware('auth');
}
public function store(StoreFormZapierWebhookRequest $request)
{
$hook = $request->instanciateHook();
$this->authorize('store', $hook);
$hook->save();
return $this->success([
'message' => 'Webhook created.',
'hook' => $hook,
]);
}
public function delete($id)
{
$hook = FormZapierWebhook::findOrFail($id);
$this->authorize('store', $hook);
$hook->delete();
return $this->success([
'message' => 'Webhook deleted.',
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Requests\AnswerFormRequest;
use App\Http\Resources\FormResource;
use App\Http\Resources\FormSubmissionResource;
use App\Jobs\Form\StoreFormSubmissionJob;
use App\Models\Forms\Form;
use App\Models\Forms\FormSubmission;
use App\Service\Forms\FormCleaner;
use App\Service\WorkspaceHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Vinkla\Hashids\Facades\Hashids;
class PublicFormController extends Controller
{
public const FILE_UPLOAD_PATH = 'forms/?/submissions';
public const TMP_FILE_UPLOAD_PATH = 'tmp/';
public function show(Request $request, string $slug)
{
$form = Form::whereSlug($slug)->whereIn('visibility', ['public', 'closed'])->firstOrFail();
if ($form->workspace == null) {
// Workspace deleted
return $this->error([
'message' => 'Form not found.',
], 404);
}
$formCleaner = new FormCleaner();
// Disable pro features if needed
$form->fill(
$formCleaner
->processForm($request, $form)
->performCleaning($form->workspace)
->getData()
);
// Increase form view counter if not login
if (!Auth::check()) {
$form->views()->create();
}
return (new FormResource($form))
->setCleanings($formCleaner->getPerformedCleanings());
}
public function listUsers(Request $request)
{
// Check that form has user field
$form = $request->form;
if (!$form->has_user_field) {
return [];
}
// Use serializer
$workspace = $form->workspace;
return (new WorkspaceHelper($workspace))->getAllUsers();
}
public function showAsset($assetFileName)
{
$path = FormController::ASSETS_UPLOAD_PATH . '/' . $assetFileName;
if (!Storage::exists($path)) {
return $this->error([
'message' => 'File not found.',
'file_name' => $assetFileName,
]);
}
$internal_url = Storage::temporaryUrl($path, now()->addMinutes(5));
foreach(config('filesystems.disks.s3.temporary_url_rewrites') as $from => $to) {
$internal_url = str_replace($from, $to, $internal_url);
}
return redirect()->to($internal_url);
}
public function answer(AnswerFormRequest $request)
{
$form = $request->form;
$submissionId = false;
if ($form->editable_submissions) {
$job = new StoreFormSubmissionJob($form, $request->validated());
$job->handle();
$submissionId = Hashids::encode($job->getSubmissionId());
} else {
StoreFormSubmissionJob::dispatch($form, $request->validated());
}
return $this->success(array_merge([
'message' => 'Form submission saved.',
'submission_id' => $submissionId,
], $request->form->is_pro && $request->form->redirect_url ? [
'redirect' => true,
'redirect_url' => $request->form->redirect_url,
] : [
'redirect' => false,
]));
}
public function fetchSubmission(Request $request, string $slug, string $submissionId)
{
$submissionId = ($submissionId) ? Hashids::decode($submissionId) : false;
$submissionId = isset($submissionId[0]) ? $submissionId[0] : false;
$form = Form::whereSlug($slug)->whereVisibility('public')->firstOrFail();
if ($form->workspace == null || !$form->editable_submissions || !$submissionId) {
return $this->error([
'message' => 'Not allowed.',
]);
}
$submission = new FormSubmissionResource(FormSubmission::findOrFail($submissionId));
$submission->publiclyAccessed();
if ($submission->form_id != $form->id) {
return $this->error([
'message' => 'Not allowed.',
], 403);
}
return $this->success($submission->toArray($request));
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use Illuminate\Http\Request;
class RecordController extends Controller
{
public function delete(Request $request, $id, $recordId)
{
$form = Form::findOrFail((int) $id);
$this->authorize('delete', $form);
$record = $form->submissions()->where('id', $recordId)->firstOrFail();
$record->delete();
return $this->success([
'message' => 'Record successfully removed.',
]);
}
}

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

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Resources\OAuthProviderResource;
use App\Integrations\OAuth\OAuthProviderService;
use App\Models\OAuthProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class OAuthProviderController extends Controller
{
public function index()
{
/** @var \App\Models\User $user */
$user = Auth::user();
$providers = $user->oauthProviders()->get();
return OAuthProviderResource::collection($providers);
}
public function connect(Request $request, OAuthProviderService $service)
{
$userId = Auth::id();
cache()->put("oauth-intention:{$userId}", $request->input('intention'), 60 * 5);
return response()->json([
'url' => $service->getDriver()->getRedirectUrl(),
]);
}
public function handleRedirect(OAuthProviderService $service)
{
$driverUser = $service->getDriver()->getUser();
$provider = OAuthProvider::query()
->updateOrCreate(
[
'user_id' => Auth::id(),
'provider' => $service,
'provider_user_id' => $driverUser->getId(),
],
[
'access_token' => $driverUser->token,
'refresh_token' => $driverUser->refreshToken,
'name' => $driverUser->getName(),
'email' => $driverUser->getEmail(),
]
);
return OAuthProviderResource::make($provider);
}
public function destroy(OAuthProvider $provider)
{
$this->authorize('delete', $provider);
$provider->delete();
return response()->json();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class PasswordController extends Controller
{
/**
* Update the user's password.
*
* @return \Illuminate\Http\Response
*/
public function update(Request $request)
{
$this->validate($request, [
'password' => 'required|confirmed|min:6',
]);
$request->user()->update([
'password' => bcrypt($request->password),
]);
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Workspace;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ProfileController extends Controller
{
/**
* Update the user's profile information.
*
* @return \Illuminate\Http\Response
*/
public function update(Request $request)
{
$user = $request->user();
$this->validate($request, [
'name' => 'required',
'email' => 'required|email|unique:users,email,' . $user->id,
]);
return tap($user)->update([
'name' => $request->name,
'email' => strtolower($request->email),
]);
}
// For self-hosted mode, only admin can update their credentials
public function updateAdminCredentials(Request $request)
{
$request->validate([
'email' => 'required|email|not_in:admin@opnform.com',
'password' => 'required|min:6|confirmed|not_in:password',
], [
'email.not_in' => "Please provide email address other than 'admin@opnform.com'",
'password.not_in' => "Please another password other than 'password'."
]);
ray('in', $request->password);
$user = $request->user();
$user->update([
'email' => $request->email,
'password' => bcrypt($request->password),
]);
ray($user);
Cache::forget('initial_user_setup_complete');
Cache::forget('max_user_id');
$workspace = Workspace::create([
'name' => 'My Workspace',
'icon' => '🧪',
]);
$user->workspaces()->sync([
$workspace->id => [
'role' => 'admin',
],
], false);
return $this->success([
'message' => 'Congratulations, your account credentials have been updated successfully.',
'user' => $user,
]);
}
}

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

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Models\Template;
use Illuminate\Http\Request;
class SitemapController extends Controller
{
public function index(Request $request)
{
return [
...$this->getTemplatesUrls(),
];
}
private function getTemplatesUrls()
{
$urls = [];
Template::where('publicly_listed', true)->chunk(100, function ($templates) use (&$urls) {
foreach ($templates as $template) {
$urls[] = [
'loc' => '/templates/'.$template->slug,
];
}
});
return $urls;
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest;
use App\Service\BillingHelper;
use App\Service\UserHelper;
use Illuminate\Support\Facades\Auth;
use Laravel\Cashier\Subscription;
class SubscriptionController extends Controller
{
public const SUBSCRIPTION_PLANS = ['monthly', 'yearly'];
public const PRO_SUBSCRIPTION_NAME = 'default';
public const SUBSCRIPTION_NAMES = [
self::PRO_SUBSCRIPTION_NAME,
];
/**
* Returns stripe checkout URL
*
* $plan is constrained with regex in the api.php
*/
public function checkout($pricing, $plan, $trial = null)
{
$this->middleware('not-subscribed');
// Check User does not have a pending subscription
$user = Auth::user();
if ($user->subscriptions()->where('stripe_status', 'past_due')->first()) {
return $this->error([
'message' => 'You already have a past due subscription. Please verify your details in the billing page,
and contact us if the issue persists.',
]);
}
$checkoutBuilder = $user
->newSubscription($pricing, BillingHelper::getPricing($pricing)[$plan])
->allowPromotionCodes();
if ($trial != null) {
$checkoutBuilder->trialUntil(now()->addDays(3)->addHour());
}
$checkout = $checkoutBuilder
->collectTaxIds()
->checkout([
'success_url' => front_url('/subscriptions/success'),
'cancel_url' => front_url('/subscriptions/error'),
'billing_address_collection' => 'required',
'customer_update' => [
'address' => 'auto',
'name' => 'never',
],
]);
return $this->success([
'checkout_url' => $checkout->url,
]);
}
public function getUsersCount()
{
$this->middleware('auth');
return [
'count' => (new UserHelper(Auth::user()))->getActiveMembersCount() - 1,
];
}
public function updateStripeDetails(UpdateStripeDetailsRequest $request)
{
$user = Auth::user();
if (!$user->hasStripeId()) {
$user->createAsStripeCustomer();
}
$user->updateStripeCustomer([
'email' => $request->email,
'name' => $request->name,
]);
return $this->success([
'message' => 'Details saved.',
]);
}
public function billingPortal()
{
$this->middleware('auth');
if (!Auth::user()->has_customer_id) {
return $this->error([
'message' => 'Please subscribe before accessing your billing portal.',
]);
}
return $this->success([
'portal_url' => Auth::user()->billingPortalUrl(front_url('/home')),
]);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Templates\FormTemplateRequest;
use App\Http\Resources\FormTemplateResource;
use App\Models\Template;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TemplateController extends Controller
{
public function index(Request $request)
{
$limit = (int) $request->get('limit', 0);
$onlyMy = (bool) $request->get('onlymy', false);
$query = Template::query();
if (Auth::check()) {
if ($onlyMy) {
$query->where('creator_id', Auth::id());
} else {
$query->where(function ($q) {
$q->where('publicly_listed', true)
->orWhere('creator_id', Auth::id());
});
}
} else {
$query->where('publicly_listed', true);
}
if ($limit > 0) {
$query->limit($limit);
}
$templates = $query->orderByDesc('created_at')->get();
return FormTemplateResource::collection($templates);
}
public function create(FormTemplateRequest $request)
{
$this->authorize('create', Template::class);
// Create template
$template = $request->getTemplate();
$template->save();
return $this->success([
'message' => 'Template was created.',
'template_id' => $template->id,
'data' => new FormTemplateResource($template),
]);
}
public function update(FormTemplateRequest $request, string $id)
{
$template = Template::findOrFail($id);
$this->authorize('update', $template);
$template->update($request->all());
return $this->success([
'message' => 'Template was updated.',
'template_id' => $template->id,
'data' => new FormTemplateResource($template),
]);
}
public function destroy($id)
{
$template = Template::findOrFail($id);
$this->authorize('delete', $template);
$template->delete();
return $this->success([
'message' => 'Template was deleted.',
]);
}
public function show(string $slug)
{
return new FormTemplateResource(
Template::whereSlug($slug)->firstOrFail()
);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\UserInvite;
use App\Models\Workspace;
use App\Service\WorkspaceHelper;
use Illuminate\Http\Request;
class UserInviteController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function listInvites(Request $request, $workspaceId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('view', $workspace);
return (new WorkspaceHelper($workspace))->getAllInvites();
}
public function resendInvite($workspaceId, $inviteId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('adminAction', $workspace);
$userInvite = $workspace->invites()->find($inviteId);
if (!$userInvite) {
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
}
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
}
$userInvite->sendEmail();
return $this->success(['message' => 'Invite email resent successfully.']);
}
public function cancelInvite($workspaceId, $inviteId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('adminAction', $workspace);
$userInvite = $workspace->invites()->find($inviteId);
if (!$userInvite) {
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
}
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
}
$userInvite->delete();
return $this->success(['message' => 'Invite deleted successfully.']);
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Jobs\Billing\RemoveWorkspaceGuests;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\UnauthorizedException;
class AppSumoController extends Controller
{
public function handle(Request $request)
{
$this->validateSignature($request);
if ($request->test) {
Log::info('[APPSUMO] test request received', $request->toArray());
return $this->success([
'message' => 'Webhook received.',
'event' => $request->event,
'success' => true,
]);
}
Log::info('[APPSUMO] request received', $request->toArray());
// Call the right function depending on the event using match()
match ($request->event) {
'activate' => $this->handleActivateEvent($request),
'upgrade', 'downgrade' => $this->handleChangeEvent($request),
'deactivate' => $this->handleDeactivateEvent($request),
default => null,
};
return $this->success([
'message' => 'Webhook received.',
'event' => $request->event,
'success' => true,
]);
}
private function handleActivateEvent($request)
{
$this->createLicense($request->json()->all());
}
private function handleChangeEvent($request)
{
$license = $this->deactivateLicense($request->prev_license_key);
$this->createLicense(array_merge($request->json()->all(), [
'user_id' => $license->user_id,
]));
}
private function handleDeactivateEvent($request)
{
$license = $this->deactivateLicense($request->license_key);
RemoveWorkspaceGuests::dispatch($license->user);
}
private function createLicense(array $licenseData): License
{
$license = License::firstOrNew([
'license_key' => $licenseData['license_key'],
'license_provider' => 'appsumo',
'status' => License::STATUS_ACTIVE,
]);
$license->meta = $licenseData;
$license->user_id = $licenseData['user_id'] ?? null;
$license->save();
Log::info(
'[APPSUMO] creating new license',
[
'license_key' => $license->license_key,
'license_id' => $license->id,
]
);
return $license;
}
private function deactivateLicense(string $licenseKey): License
{
$license = License::where([
'license_key' => $licenseKey,
'license_provider' => 'appsumo',
])->firstOrFail();
$license->update([
'status' => License::STATUS_INACTIVE,
]);
Log::info('[APPSUMO] De-activating license', [
'license_key' => $licenseKey,
'license_id' => $license->id,
]);
return $license;
}
private function validateSignature(Request $request)
{
$signature = $request->header('x-appsumo-signature');
$payload = $request->getContent();
if ($signature === hash_hmac('sha256', $payload, config('services.appsumo.api_key'))) {
throw new UnauthorizedException('Invalid signature.');
}
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Notifications\Subscription\FailedPaymentNotification;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Laravel\Cashier\Http\Controllers\WebhookController;
use Stripe\Subscription as StripeSubscription;
class StripeController extends WebhookController
{
public function handleCustomerSubscriptionCreated(array $payload)
{
return parent::handleCustomerSubscriptionCreated($payload);
}
/**
* Override to add a sleep, and to detect plan upgrades
*
* @return \Symfony\Component\HttpFoundation\Response|void
*/
protected function handleCustomerSubscriptionUpdated(array $payload)
{
sleep(1);
if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
$data = $payload['data']['object'];
$subscription = $user->subscriptions()->firstOrNew(['stripe_id' => $data['id']]);
if (
isset($data['status']) &&
$data['status'] === StripeSubscription::STATUS_INCOMPLETE_EXPIRED
) {
$subscription->items()->delete();
$subscription->delete();
return;
}
$subscription->type = $subscription->type ?? $data['metadata']['name'] ?? $this->newSubscriptionName($payload);
$mainItem = $this->getMainSubscriptionLineItem($data['items']['data']);
$isSinglePrice = count($data['items']['data']) === 1;
// Price...
$subscription->stripe_price = $isSinglePrice ? $mainItem['price']['id'] : null;
// Type - previously (Name)
$subscription->type = $this->getSubscriptionName($mainItem['price']['product']);
// Quantity...
$subscription->quantity = $isSinglePrice && isset($mainItem['quantity']) ? $mainItem['quantity'] : null;
// Trial ending date...
if (isset($data['trial_end'])) {
$trialEnd = Carbon::createFromTimestamp($data['trial_end']);
if (! $subscription->trial_ends_at || $subscription->trial_ends_at->ne($trialEnd)) {
$subscription->trial_ends_at = $trialEnd;
}
}
// Cancellation date...
if (isset($data['cancel_at_period_end'])) {
if ($data['cancel_at_period_end']) {
$subscription->ends_at = $subscription->onTrial()
? $subscription->trial_ends_at
: Carbon::createFromTimestamp($data['current_period_end']);
} elseif (isset($data['cancel_at'])) {
$subscription->ends_at = Carbon::createFromTimestamp($data['cancel_at']);
} else {
$subscription->ends_at = null;
}
}
// Status...
if (isset($data['status'])) {
$subscription->stripe_status = $data['status'];
}
$subscription->save();
// Update subscription items...
if (isset($data['items'])) {
$prices = [];
foreach ($data['items']['data'] as $item) {
$prices[] = $item['price']['id'];
$subscription->items()->updateOrCreate([
'stripe_id' => $item['id'],
], [
'stripe_product' => $item['price']['product'],
'stripe_price' => $item['price']['id'],
'quantity' => $item['quantity'] ?? null,
]);
}
// Delete items that aren't attached to the subscription anymore...
$subscription->items()->whereNotIn('stripe_price', $prices)->delete();
}
}
return $this->successMethod();
}
protected function handleChargeFailed(array $payload)
{
if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
$user->notify(new FailedPaymentNotification());
}
return $this->successMethod();
}
private function getMainSubscriptionLineItem(array $items)
{
return collect($items)->first(function ($item) {
return in_array($this->getSubscriptionName($item['price']['product']), ['default']);
});
}
private function getSubscriptionName(string $stripeProductId)
{
$config = App::environment() == 'production' ? config('pricing.production') : config('pricing.test');
foreach ($config as $plan => $data) {
if ($stripeProductId == $config[$plan]['product_id']) {
return $plan;
}
}
return 'default';
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Workspace\CustomDomainRequest;
use App\Http\Resources\WorkspaceResource;
use App\Models\Workspace;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WorkspaceController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index()
{
$this->authorize('viewAny', Workspace::class);
return WorkspaceResource::collection(Auth::user()->workspaces);
}
public function saveCustomDomain(CustomDomainRequest $request)
{
$request->workspace->custom_domains = $request->customDomains;
$request->workspace->save();
return new WorkspaceResource($request->workspace);
}
public function delete($id)
{
$workspace = Workspace::findOrFail($id);
$this->authorize('delete', $workspace);
$id = $workspace->id;
$workspace->delete();
return $this->success([
'message' => 'Workspace deleted.',
'workspace_id' => $id,
]);
}
public function create(Request $request)
{
$user = $request->user();
$this->validate($request, [
'name' => 'required',
]);
// Create workspace
$workspace = Workspace::create([
'name' => $request->name,
'icon' => ($request->emoji) ? $request->emoji : '',
]);
// Add relation with user
$user->workspaces()->sync([
$workspace->id => [
'role' => 'admin',
],
], false);
return $this->success([
'message' => 'Workspace created.',
'workspace_id' => $workspace->id,
'workspace' => new WorkspaceResource($workspace),
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\Billing\WorkspaceUsersUpdated;
use App\Models\UserInvite;
use Illuminate\Http\Request;
use App\Models\Workspace;
use App\Models\User;
use App\Service\WorkspaceHelper;
class WorkspaceUserController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function listUsers(Request $request, $workspaceId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('view', $workspace);
return (new WorkspaceHelper($workspace))->getAllUsers();
}
public function addUser(Request $request, $workspaceId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('inviteUser', $workspace);
$this->validate($request, [
'email' => 'required|email',
'role' => 'required|in:admin,user',
]);
$user = User::where('email', $request->email)->first();
if (!$user) {
return $this->inviteUser($workspace, $request->email, $request->role);
}
if ($workspace->users->contains($user->id)) {
return $this->success([
'message' => 'User is already in workspace.'
]);
}
// User found - add user to workspace
$workspace->users()->sync([
$user->id => [
'role' => $request->role,
],
], false);
WorkspaceUsersUpdated::dispatch($workspace);
return $this->success([
'message' => 'User has been successfully added to workspace.'
]);
}
private function inviteUser(Workspace $workspace, string $email, string $role)
{
if (
UserInvite::where('email', $email)
->where('workspace_id', $workspace->id)
->notExpired()
->pending()
->exists()) {
return $this->success([
'message' => 'User has already been invited.'
]);
}
// Send new invite
UserInvite::inviteUser($email, $role, $workspace, now()->addDays(7));
return $this->success([
'message' => 'Registration invitation email sent to user.'
]);
}
public function updateUserRole(Request $request, $workspaceId, $userId)
{
$workspace = Workspace::findOrFail($workspaceId);
$user = User::findOrFail($userId);
$this->authorize('adminAction', $workspace);
$this->validate($request, [
'role' => 'required|in:admin,user',
]);
$workspace->users()->sync([
$user->id => [
'role' => $request->role,
],
], false);
return $this->success([
'message' => 'User role changed successfully.'
]);
}
public function removeUser(Request $request, $workspaceId, $userId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('adminAction', $workspace);
$workspace->users()->detach($userId);
WorkspaceUsersUpdated::dispatch($workspace);
return $this->success([
'message' => 'User removed from workspace successfully.'
]);
}
public function leaveWorkspace(Request $request, $workspaceId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('view', $workspace);
$workspace->users()->detach($request->user()->id);
return $this->success([
'message' => 'You have left the workspace successfully.'
]);
}
}

102
api/app/Http/Kernel.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http;
use App\Http\Middleware\AcceptsJsonMiddleware;
use App\Http\Middleware\AuthenticateJWT;
use App\Http\Middleware\CustomDomainRestriction;
use App\Http\Middleware\ImpersonationMiddleware;
use App\Http\Middleware\IsAdmin;
use App\Http\Middleware\IsModerator;
use App\Http\Middleware\IsNotSubscribed;
use App\Http\Middleware\IsSubscribed;
use App\Http\Middleware\SelfHostedCredentialsMiddleware;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\SetLocale::class,
AuthenticateJWT::class,
CustomDomainRestriction::class,
AcceptsJsonMiddleware::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'spa' => [
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:100,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Session\Middleware\StartSession::class,
SelfHostedCredentialsMiddleware::class,
ImpersonationMiddleware::class,
],
'api-external' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'admin' => IsAdmin::class,
'moderator' => IsModerator::class,
'subscribed' => IsSubscribed::class,
'not-subscribed' => IsNotSubscribed::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'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\Middleware;
use Closure;
use Illuminate\Http\Request;
class AcceptsJsonMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return redirect(front_url('login'));
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Tymon\JWTAuth\Exceptions\JWTException;
class AuthenticateJWT
{
public const API_SERVER_SECRET_HEADER_NAME = 'x-api-secret';
/**
* Verifies the JWT token and validates the IP and User Agent
* Invalidates token otherwise
*/
public function handle(Request $request, Closure $next)
{
// Parse JWT Payload
try {
$payload = \JWTAuth::parseToken()->getPayload();
} catch (JWTException $e) {
return $next($request);
}
// Validate IP and User Agent
if ($payload) {
if ($frontApiSecret = $request->header(self::API_SERVER_SECRET_HEADER_NAME)) {
// If it's a trusted SSR request, skip the rest
if ($frontApiSecret === config('app.front_api_secret')) {
return $next($request);
}
}
// If it's impersonating, skip the rest
if ($payload->get('impersonating')) {
return $next($request);
}
$error = null;
if (! \Hash::check($request->ip(), $payload->get('ip'))) {
$error = 'Origin IP is invalid';
}
if (! \Hash::check($request->userAgent(), $payload->get('ua'))) {
$error = 'Origin User Agent is invalid';
}
if ($error) {
auth()->invalidate();
return response()->json([
'message' => $error,
], 403);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CaddyRequestMiddleware
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
if (! config('custom-domains.enabled')) {
return response()->json([
'success' => false,
'message' => 'Custom domains not enabled',
], 401);
}
if (config('custom-domains.enabled') && ! in_array($request->ip(), config('custom-domains.authorized_ips'))) {
return response()->json([
'success' => false,
'message' => 'Unauthorized IP',
], 401);
}
$secret = $request->route('secret');
if (config('custom-domains.caddy_secret') && (! $secret || $secret !== config('custom-domains.caddy_secret'))) {
return response()->json([
'success' => false,
'message' => 'Unauthorized',
], 401);
}
return $next($request);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Middleware;
use App\Http\Requests\Workspace\CustomDomainRequest;
use App\Models\Forms\Form;
use App\Models\Workspace;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class CustomDomainRestriction
{
public const CUSTOM_DOMAIN_HEADER = 'x-custom-domain';
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
if (! $request->hasHeader(self::CUSTOM_DOMAIN_HEADER) || ! config('custom-domains.enabled')) {
return $next($request);
}
$customDomain = $request->header(self::CUSTOM_DOMAIN_HEADER);
if (! preg_match(CustomDomainRequest::CUSTOM_DOMAINS_REGEX, $customDomain)) {
return response()->json([
'success' => false,
'message' => 'Invalid domain',
'error' => 'invalid_domain',
], 420);
}
// Check if domain is different from current domain
$notionFormsDomain = parse_url(config('app.url'))['host'];
if ($customDomain == $notionFormsDomain) {
return $next($request);
}
// Check if domain is known
if (! $workspaces = Workspace::whereJsonContains('custom_domains', $customDomain)->get()) {
return response()->json([
'success' => false,
'message' => 'Unknown domain',
'error' => 'invalid_domain',
], 420);
}
$workspacesIds = $workspaces->pluck('id')->toArray();
Workspace::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspacesIds) {
$builder->whereIn('id', $workspacesIds);
});
Form::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspacesIds) {
$builder->whereIn('workspace_id', $workspacesIds);
});
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware\Form;
use App\Models\Forms\Form;
use Closure;
use Illuminate\Http\Request;
class ProForm
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
if ($request->route('formId') && $form = Form::findOrFail($request->route('formId'))) {
if ($form->is_pro) {
$request->merge([
'form' => $form,
]);
return $next($request);
}
}
return response([
'status' => 'Unauthorized',
'message' => 'You need a subscription to access this content.',
], 403);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Middleware\Form;
use App\Models\Forms\Form;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ProtectedForm
{
public const PASSWORD_HEADER_NAME = 'form-password';
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
if (! $request->route('slug')) {
return $next($request);
}
$form = Form::where('slug', $request->route('slug'))->firstOrFail();
$request->merge([
'form' => $form,
]);
$userIsFormOwner = Auth::check() && Auth::user()->ownsForm($form);
if (! $userIsFormOwner && $this->isProtected($request, $form)) {
return response([
'status' => 'Unauthorized',
'message' => 'Form is protected.',
], 403);
}
return $next($request);
}
public static function isProtected(Request $request, Form $form)
{
if (! $form->has_password) {
return false;
}
return ! self::hasCorrectPassword($request, $form);
}
public static function hasCorrectPassword(Request $request, Form $form)
{
return $request->headers->has(self::PASSWORD_HEADER_NAME) && $request->headers->get(self::PASSWORD_HEADER_NAME) == hash('sha256', $form->password);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware\Form;
use App\Models\Forms\Form;
use Closure;
use Illuminate\Http\Request;
class ResolveFormMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next, string $routeParamName = 'id')
{
$form = Form::where($routeParamName, $request->route($routeParamName))->firstOrFail();
$request->merge([
'form' => $form,
]);
return $next($request);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Tymon\JWTAuth\Exceptions\JWTException;
class ImpersonationMiddleware
{
public const ADMIN_LOG_PREFIX = '[admin_action] ';
public const LOG_ROUTES = [
'open.forms.store',
'open.forms.update',
'open.forms.duplicate',
'open.forms.regenerate-link',
];
public const ALLOWED_ROUTES = [
'logout',
// Forms
'forms.ai.generate',
'forms.ai.show',
'forms.assets.show',
'forms.show',
'forms.answer',
'forms.fetchSubmission',
'forms.users.index',
'open.forms.index-all',
'open.forms.store',
'open.forms.assets.upload',
'open.forms.update',
'open.forms.duplicate',
'open.forms.regenerate-link',
'open.forms.submissions',
'open.forms.submissions.file',
'open.providers',
'open.forms.integrations',
'open.forms.integrations.events',
// Workspaces
'open.workspaces.index',
'open.workspaces.create',
'open.workspaces.delete',
'open.workspaces.save-custom-domains',
'open.workspaces.databases.search',
'open.workspaces.databases.show',
'open.workspaces.form.stats',
'open.workspaces.forms.index',
'open.workspaces.users.index',
'templates.index',
'templates.create',
'templates.update',
'templates.show',
'user.current',
'local.temp',
'vapor.signed-storage-url',
'upload-file'
];
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
try {
if (!auth()->check() || !auth()->payload()->get('impersonating')) {
return $next($request);
}
} catch (JWTException $e) {
return $next($request);
}
// Check that route is allowed
$routeName = $request->route()->getName();
if (!in_array($routeName, self::ALLOWED_ROUTES)) {
return response([
'message' => 'Unauthorized when impersonating',
'route' => $routeName,
'impersonator' => auth()->payload()->get('impersonator_id'),
'impersonated_account' => auth()->id(),
'url' => $request->fullUrl(),
'payload' => $request->all(),
], 403);
} elseif (in_array($routeName, self::LOG_ROUTES)) {
\Log::warning(self::ADMIN_LOG_PREFIX . 'Impersonator action', [
'route' => $routeName,
'url' => $request->fullUrl(),
'impersonated_account' => auth()->id(),
'impersonator' => auth()->payload()->get('impersonator_id'),
'payload' => $request->all(),
]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IsAdmin
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->user() && ! $request->user()->admin) {
// This user is not a paying customer...
if ($request->expectsJson()) {
return response([
'message' => 'You are not allowed.',
'type' => 'error',
], 403);
}
return redirect('home');
}
return $next($request);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IsModerator
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->user() && ! $request->user()->moderator) {
// This user is not a paying customer...
if ($request->expectsJson()) {
return response([
'message' => 'You are not allowed.',
'type' => 'error',
], 403);
}
return redirect('home');
}
return $next($request);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IsNotSubscribed
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->user() && $request->user()->subscribed()) {
// This user is a paying customer...
if ($request->expectsJson()) {
return response([
'message' => 'You are already subscribed to NotionForms Pro.',
'type' => 'error',
], 401);
}
return redirect('billing');
}
return $next($request);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IsSubscribed
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->user() && ! $request->user()->subscribed()) {
// This user is not a paying customer...
if ($request->expectsJson()) {
return response([
'message' => 'You are not subscribed to NotionForms Pro.',
'type' => 'error',
], 401);
}
return redirect('billing');
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param string|null ...$guards
* @return mixed
*/
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Already authenticated.'], 400);
} else {
return redirect(RouteServiceProvider::HOME);
}
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Cache;
use App\Models\User;
class SelfHostedCredentialsMiddleware
{
public const ALLOWED_ROUTES = [
'login',
'credentials.update',
'user.current',
'logout',
];
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (app()->environment('testing')) {
return $next($request);
}
if (in_array($request->route()->getName(), self::ALLOWED_ROUTES)) {
return $next($request);
}
if (
config('app.self_hosted') &&
$request->user() &&
!$this->isInitialSetupComplete()
) {
return response()->json([
'message' => 'You must change your credentials when in self-hosted mode',
'type' => 'error',
], Response::HTTP_FORBIDDEN);
}
return $next($request);
}
private function isInitialSetupComplete(): bool
{
return (bool) Cache::remember('initial_user_setup_complete', 60 * 60, function () {
$maxUserId = $this->getMaxUserId();
if ($maxUserId === 0) {
return false;
}
return !User::where('email', 'admin@opnform.com')->exists();
});
}
private function getMaxUserId(): int
{
return (int) Cache::remember('max_user_id', 60 * 60, function () {
return User::max('id') ?? 0;
});
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Closure;
class SetLocale
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($locale = $this->parseLocale($request)) {
app()->setLocale($locale);
}
return $next($request);
}
/**
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function parseLocale($request)
{
$locales = config('app.locales');
$locale = $request->server('HTTP_ACCEPT_LANGUAGE');
$locale = substr($locale, 0, strpos($locale, ',') ?: strlen($locale));
if (array_key_exists($locale, $locales)) {
return $locale;
}
$locale = substr($locale, 0, 2);
if (array_key_exists($locale, $locales)) {
return $locale;
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'password',
'password_confirmation',
];
/**
* The route name where this shouldn't be applied
*
* @var string[]
*/
protected $exceptUrls = [
'/\/api\/forms\/(.*)\/answer/',
];
public function handle($request, \Closure $next)
{
// Check if URL matches
foreach ($this->exceptUrls as $urlRegex) {
$matches = null;
preg_match($urlRegex, $request->url(), $matches);
if (count($matches)) {
return $next($request);
}
}
return parent::handle($request, $next);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array
*/
public function hosts()
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
'stripe/webhook',
'vapor/signed-storage-url',
'upload-file',
];
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AiGenerateFormRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'form_prompt' => 'required|string|max:1000',
];
}
}

View File

@@ -0,0 +1,281 @@
<?php
namespace App\Http\Requests;
use App\Models\Forms\Form;
use App\Rules\CustomFieldValidationRule;
use App\Rules\MatrixValidationRule;
use App\Rules\StorageFile;
use App\Rules\ValidHCaptcha;
use App\Rules\ValidPhoneInputRule;
use App\Rules\ValidUrl;
use App\Service\Forms\FormLogicPropertyResolver;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class AnswerFormRequest extends FormRequest
{
public Form $form;
protected array $requestRules = [];
protected int $maxFileSize;
public function __construct(Request $request)
{
$this->form = $request->form;
$this->maxFileSize = $this->form->workspace->max_file_size;
}
private function getFieldMaxFileSize($fieldProps)
{
return array_key_exists('max_file_size', $fieldProps) ?
min($fieldProps['max_file_size'] * 1000000, $this->maxFileSize) : $this->maxFileSize;
}
/**
* Validate form before use it
*
* @return bool
*/
public function authorize()
{
return !$this->form->is_closed && !$this->form->max_number_of_submissions_reached && $this->form->visibility === 'public';
}
/**
* Get the validation rules that apply to the form.
*
* @return array
*/
public function rules()
{
$selectionFields = collect($this->form->properties)->filter(function ($pro) {
return in_array($pro['type'], ['select', 'multi_select']);
});
foreach ($this->form->properties as $property) {
$rules = [];
/*if (!$this->form->is_pro) { // If not pro then not check logic
$property['logic'] = false;
}*/
// For get values instead of Id for select/multi select options
$data = $this->toArray();
foreach ($selectionFields as $field) {
if (isset($data[$field['id']]) && is_array($data[$field['id']])) {
$data[$field['id']] = array_map(function ($val) use ($field) {
$tmpop = collect($field[$field['type']]['options'])->first(function ($op) use ($val) {
return $op['id'] ?? $op['value'] === $val;
});
return isset($tmpop['name']) ? $tmpop['name'] : '';
}, $data[$field['id']]);
}
}
if (FormLogicPropertyResolver::isRequired($property, $data)) {
$rules[] = 'required';
if ($property['type'] == 'checkbox') {
// Required for checkboxes means true
$rules[] = 'accepted';
} elseif ($property['type'] == 'rating') {
// For star rating, needs a minimum of 1 star
$rules[] = 'min:1';
} elseif ($property['type'] == 'matrix') {
$rules[] = new MatrixValidationRule($property, true);
}
} else {
$rules[] = 'nullable';
if ($property['type'] == 'matrix') {
$rules[] = new MatrixValidationRule($property, false);
}
}
// Clean id to escape "."
$propertyId = $property['id'];
if (in_array($property['type'], ['multi_select'])) {
$rules[] = 'array';
$this->requestRules[$propertyId . '.*'] = $this->getPropertyRules($property);
} else {
$rules = array_merge($rules, $this->getPropertyRules($property));
}
// User custom validation
if (!(Str::of($property['type'])->startsWith('nf-')) && isset($property['validation'])) {
$rules[] = (new CustomFieldValidationRule($property['validation'], $data));
}
$this->requestRules[$propertyId] = $rules;
}
// Validate hCaptcha
if ($this->form->use_captcha) {
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()];
}
// Validate submission_id for edit mode
if ($this->form->is_pro && $this->form->editable_submissions) {
$this->requestRules['submission_id'] = 'string';
}
return $this->requestRules;
}
/**
* Renames validated fields (because field names are ids)
*
* @return array
*/
public function attributes()
{
$fields = [];
foreach ($this->form->properties as $property) {
$fields[$property['id']] = $property['name'];
}
return $fields;
}
/**
* Get the validation messages that apply to the request.
*
* @return array
*/
public function messages()
{
$messages = [];
foreach ($this->form->properties as $property) {
if ($property['type'] == 'date' && isset($property['date_range']) && $property['date_range']) {
$messages[$property['id'] . '.0.required_with'] = 'From date is required';
$messages[$property['id'] . '.1.required_with'] = 'To date is required';
$messages[$property['id'] . '.0.before_or_equal'] = 'From date must be before or equal To date';
}
if ($property['type'] == 'rating') {
$messages[$property['id'] . '.min'] = 'A rating must be selected';
}
}
return $messages;
}
/**
* Return validation rules for a given form property
*/
private function getPropertyRules($property): array
{
switch ($property['type']) {
case 'text':
case 'signature':
return ['string'];
case 'number':
case 'rating':
case 'scale':
case 'slider':
return ['numeric'];
case 'select':
case 'multi_select':
if (($property['allow_creation'] ?? false)) {
return ['string'];
}
return [Rule::in($this->getSelectPropertyOptions($property))];
case 'checkbox':
return ['boolean'];
case 'url':
if (isset($property['file_upload']) && $property['file_upload']) {
$this->requestRules[$property['id'] . '.*'] = [new StorageFile($this->maxFileSize, [], $this->form)];
return ['array'];
}
return [new ValidUrl()];
case 'files':
$allowedFileTypes = [];
if (!empty($property['allowed_file_types'])) {
$allowedFileTypes = explode(',', $property['allowed_file_types']);
}
$this->requestRules[$property['id'] . '.*'] = [new StorageFile($this->getFieldMaxFileSize($property), $allowedFileTypes, $this->form)];
return ['array'];
case 'email':
return ['email:filter'];
case 'date':
if (isset($property['date_range']) && $property['date_range']) {
$this->requestRules[$property['id'] . '.*'] = $this->getRulesForDate($property);
$this->requestRules[$property['id'] . '.0'] = ['required_with:' . $property['id'] . '.1', 'before_or_equal:' . $property['id'] . '.1'];
$this->requestRules[$property['id'] . '.1'] = ['required_with:' . $property['id'] . '.0'];
return ['array', 'min:2'];
}
return $this->getRulesForDate($property);
case 'phone_number':
if (isset($property['use_simple_text_input']) && $property['use_simple_text_input']) {
return ['string'];
}
return ['string', 'min:6', new ValidPhoneInputRule()];
default:
return [];
}
}
private function getRulesForDate($property)
{
if (isset($property['disable_past_dates']) && $property['disable_past_dates']) {
return ['date', 'after:yesterday'];
} elseif (isset($property['disable_future_dates']) && $property['disable_future_dates']) {
return ['date', 'before:tomorrow'];
}
return ['date'];
}
private function getSelectPropertyOptions($property): array
{
$type = $property['type'];
if (!isset($property[$type])) {
return [];
}
return array_column($property[$type]['options'], 'name');
}
protected function prepareForValidation()
{
$receivedData = $this->toArray();
$mergeData = [];
$countryCodeMapper = json_decode(file_get_contents(resource_path('data/country_code_mapper.json')), true);
collect($this->form->properties)->each(function ($property) use ($countryCodeMapper, $receivedData, &$mergeData) {
$receivedValue = $receivedData[$property['id']] ?? null;
// Escape all '\' in select options
if (in_array($property['type'], ['select', 'multi_select']) && !is_null($receivedValue)) {
if (is_array($receivedValue)) {
$mergeData[$property['id']] = collect($receivedValue)->map(function ($value) {
$value = Str::of($value);
return $value->replace(
["\e", "\f", "\n", "\r", "\t", "\v", '\\'],
['\\e', '\\f', '\\n', '\\r', '\\t', '\\v', '\\\\']
)->toString();
})->toArray();
} else {
$receivedValue = Str::of($receivedValue);
$mergeData[$property['id']] = $receivedValue->replace(
["\e", "\f", "\n", "\r", "\t", "\v", '\\'],
['\\e', '\\f', '\\n', '\\r', '\\t', '\\v', '\\\\']
)->toString();
}
}
if ($property['type'] === 'phone_number' && (!isset($property['use_simple_text_input']) || !$property['use_simple_text_input']) && $receivedValue && in_array($receivedValue, $countryCodeMapper)) {
$mergeData[$property['id']] = null;
}
});
$this->merge($mergeData);
}
}

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,97 @@
<?php
namespace App\Http\Requests\Integration;
use App\Models\Integration\FormIntegration;
use App\Rules\IntegrationLogicRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class FormIntegrationsRequest extends FormRequest
{
public array $integrationRules = [];
private ?string $integrationClassName = null;
public function __construct(Request $request)
{
if ($request->integration_id) {
// Load integration class, and get rules
$integration = FormIntegration::getIntegration($request->integration_id);
if ($integration && isset($integration['file_name']) && class_exists(
'App\Integrations\Handlers\\' . $integration['file_name']
)) {
$this->integrationClassName = 'App\Integrations\Handlers\\' . $integration['file_name'];
$this->loadIntegrationRules();
return;
}
throw new \Exception('Unknown Integration!');
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return array_merge([
'integration_id' => ['required', Rule::in(array_keys(FormIntegration::getAllIntegrations()))],
'oauth_id' => [
$this->isOAuthRequired() ? 'required' : 'nullable',
Rule::exists('oauth_providers', 'id')
],
'settings' => 'present|array',
'status' => 'required|boolean',
'logic' => [new IntegrationLogicRule()],
], $this->integrationRules);
}
/**
* Give the validated fields a better "human-readable" name
*
* @return array
*/
public function attributes()
{
$attributes = $this->integrationClassName::getValidationAttributes();
$fields = [];
foreach ($this->rules() as $key => $value) {
$fields[$key] = $attributes[$key] ?? Str::of($key)
->replace('settings.', '')
->headline()
->toString();
}
return $fields;
}
protected function isOAuthRequired(): bool
{
return $this->integrationClassName::isOAuthRequired();
}
private function loadIntegrationRules()
{
foreach ($this->integrationClassName::getValidationRules() as $key => $value) {
$this->integrationRules['settings.' . $key] = $value;
}
}
public function toIntegrationData(): array
{
return $this->integrationClassName::formatData([
'status' => ($this->validated(
'status'
)) ? FormIntegration::STATUS_ACTIVE : FormIntegration::STATUS_INACTIVE,
'integration_id' => $this->validated('integration_id'),
'data' => $this->validated('settings') ?? [],
'logic' => $this->validated('logic') ?? [],
'oauth_id' => $this->validated('oauth_id'),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Integration;
use App\Models\Forms\Form;
use App\Models\Integration\FormZapierWebhook;
use Illuminate\Foundation\Http\FormRequest;
class StoreFormZapierWebhookRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'form_slug' => 'required|exists:forms,slug',
'hook_url' => 'required|string|url',
];
}
public function instanciateHook()
{
$form = Form::whereSlug($this->form_slug)->firstOrFail();
return new FormZapierWebhook([
'form_id' => $form->id,
'hook_url' => $this->hook_url,
]);
}
}

View File

@@ -0,0 +1,30 @@
<?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(), 'id'),
],
];
}
public function form(): Form
{
return Form::findOrFail($this->input('form_id'));
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests;
class StoreFormRequest extends UserFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return array_merge(parent::rules(), [// Info about database
'workspace_id' => 'required|exists:workspaces,id',
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Subscriptions;
use Illuminate\Foundation\Http\FormRequest;
class UpdateStripeDetailsRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'name' => 'required|string',
'email' => 'required|email',
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Requests\Templates;
use App\Models\Template;
use Illuminate\Foundation\Http\FormRequest;
class FormTemplateRequest extends FormRequest
{
public const IGNORED_KEYS = [
'id',
'creator',
'cleanings',
'closes_at',
'deleted_at',
'updated_at',
'form_pending_submission_key',
'is_closed',
'is_pro',
'is_password_protected',
'last_edited_human',
'max_number_of_submissions_reached',
'removed_properties',
'creator_id',
'extra',
'workspace',
'workspace_id',
'submissions',
'submissions_count',
'views',
'views_count',
'visibility',
'webhook_url',
];
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
$slugRule = '';
if ($this->id) {
$slugRule = ',' . $this->id;
}
return [
'form' => 'required|array',
'publicly_listed' => 'boolean',
'name' => 'required|string|max:60',
'slug' => 'required|string|alpha_dash|unique:templates,slug' . $slugRule,
'short_description' => 'required|string|max:1000',
'description' => 'required|string',
'image_url' => 'required|string',
'types' => 'nullable|array',
'industries' => 'nullable|array',
'related_templates' => 'nullable|array',
'questions' => 'array',
];
}
public function getTemplate(): Template
{
$structure = $this->form;
foreach ($structure as $key => $val) {
if (in_array($key, self::IGNORED_KEYS)) {
unset($structure[$key]);
}
}
return new Template([
'creator_id' => $this->user()?->id ?? null,
'publicly_listed' => $this->publicly_listed,
'name' => $this->name,
'slug' => $this->slug,
'short_description' => $this->short_description,
'description' => $this->description,
'image_url' => $this->image_url,
'structure' => $structure,
'types' => $this->types ?? [],
'industries' => $this->industries ?? [],
'related_templates' => $this->related_templates ?? [],
'questions' => $this->questions ?? [],
]);
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Http\Requests;
class UpdateFormRequest extends UserFormRequest
{
}

Some files were not shown because too many files have changed in this diff Show More