├── .dockerignore ├── .env.example ├── .github ├── assets │ ├── logo-dark.png │ └── logo-light.png └── workflows │ ├── build.yml │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE.txt ├── README.md ├── apps ├── platform │ ├── .dockerignore │ ├── .env.example │ ├── .eslintrc │ ├── .gitignore │ ├── @types │ │ └── knex.d.ts │ ├── Dockerfile │ ├── db │ │ ├── migration.stub │ │ └── migrations │ │ │ ├── 0_init.js │ │ │ ├── 20220730041531_create_journeys.js │ │ │ ├── 20220818010625_create_list.js │ │ │ ├── 20220823000825_add_providers_table.js │ │ │ ├── 20220905194347_add_campaigns.js │ │ │ ├── 20220920022913_add_media_library.js │ │ │ ├── 20221105203054_add_refresh_tokens.js │ │ │ ├── 20221118223317_stored_token_rename.js │ │ │ ├── 20221122013145_add_campaign_fields.js │ │ │ ├── 20221210193723_add_tags.js │ │ │ ├── 20221224170020_list_modifications.js │ │ │ ├── 20230107165010_add_campaign_send_table.js │ │ │ ├── 20230120144724_add_journey_step_uuid.js │ │ │ ├── 20230204200316_step_type_x_y_float.js │ │ │ ├── 20230210024314_add_project_timezone.js │ │ │ ├── 20230212215653_rename_journey_steps_uuid.js │ │ │ ├── 20230226042803_add_user_list_version.js │ │ │ ├── 20230303141811_add_schedule_lock.js │ │ │ ├── 20230306220639_add_journey_step_child_priority.js │ │ │ ├── 20230317133712_update_campaign_multiple_lists.js │ │ │ ├── 20230319192319_add_project_roles.js │ │ │ ├── 20230326145047_drop_external_id_column.js │ │ │ ├── 20230331203356_improve_user_indexes.js │ │ │ ├── 20230401212338_add_user_locale_column.js │ │ │ ├── 20230403152432_add_admin_profile_image.js │ │ │ ├── 20230407162219_add_campaign_stats.js │ │ │ ├── 20230418041529_add_provider_rate_limiter.js │ │ │ ├── 20230419032608_users_allow_null_external_id.js │ │ │ ├── 20230427012022_add_list_soft_delete.js │ │ │ ├── 20230504131759_add_user_indexes.js │ │ │ ├── 20230505020951_add_project_rule_paths.js │ │ │ ├── 20230514192033_add_organization.js │ │ │ ├── 20230516214727_add_campaign_type.js │ │ │ ├── 20230602115106_journey_add_published.js │ │ │ ├── 20230603152941_admins_last_name_nullable.js │ │ │ ├── 20230624135258_project_sms_opt_out.js │ │ │ ├── 20230626205637_add_user_event_index.js │ │ │ ├── 20230627184626_journey_step_gate_migration.js │ │ │ ├── 20230707221741_add_campaign_send_step_id.js │ │ │ ├── 20230803030205_add_settings_to_org.js │ │ │ ├── 20230813184853_add_journey_user_step_delay_until.js │ │ │ ├── 20230814014024_add_locales.js │ │ │ ├── 20230827174518_add_user_events_date_index.js │ │ │ ├── 20230828003728_add_user_list_index.js │ │ │ ├── 20230905142435_add_journey_step_data_and_json_stats.js │ │ │ ├── 20230910182600_add_journey_stats.js │ │ │ ├── 20230919110256_add_journey_step_name.js │ │ │ ├── 20231008171139_update_admin_invite.js │ │ │ ├── 20231013201848_add_rule_table.js │ │ │ ├── 20231013222057_add_journey_user_step_ref_index.js │ │ │ ├── 20231203145249_change_journey_entrance_case.js │ │ │ ├── 20240310020733_add_admin_organization_role.js │ │ │ ├── 20240315211220_add_reference_to_campaign_send.js │ │ │ ├── 20240323145423_add_journey_step_child_key.js │ │ │ ├── 20240419015246_add_project_text_help.js │ │ │ ├── 20240808205738_add_provider_rate_interval.js │ │ │ ├── 20240914230319_add_resources.js │ │ │ ├── 20241012155809_add_push_link_wrapping.js │ │ │ ├── 20241017002221_reset_list_totals.js │ │ │ ├── 20241109235323_add_list_refreshed_at.js │ │ │ ├── 20241119174518_add_journey_user_step_timestamp_index.js │ │ │ ├── 20241228214210_modify_journey_user_step_indexes.js │ │ │ ├── 20250307055453_update_campaign_send_primary_keys.js │ │ │ └── 20250308062039_add_provider_soft_delete.js │ ├── global.d.ts │ ├── jest.config.js │ ├── nodemon.json │ ├── package-lock.json │ ├── package.json │ ├── public │ │ └── uploads │ │ │ └── .gitkeep │ ├── scripts │ │ ├── create-migration.mjs │ │ └── output-migration.mjs │ ├── src │ │ ├── api.ts │ │ ├── app.ts │ │ ├── auth │ │ │ ├── AccessToken.ts │ │ │ ├── Admin.ts │ │ │ ├── AdminController.ts │ │ │ ├── AdminRepository.ts │ │ │ ├── Auth.ts │ │ │ ├── AuthController.ts │ │ │ ├── AuthError.ts │ │ │ ├── AuthMiddleware.ts │ │ │ ├── AuthProvider.ts │ │ │ ├── BasicAuthProvider.ts │ │ │ ├── EmailAuthProvider.ts │ │ │ ├── GoogleAuthProvider.ts │ │ │ ├── MultiAuthProvider.ts │ │ │ ├── OpenIDAuthProvider.ts │ │ │ ├── SAMLAuthProvider.ts │ │ │ └── TokenRepository.ts │ │ ├── boot.ts │ │ ├── campaigns │ │ │ ├── Campaign.ts │ │ │ ├── CampaignAbortJob.ts │ │ │ ├── CampaignController.ts │ │ │ ├── CampaignEnqueueSendsJob.ts │ │ │ ├── CampaignError.ts │ │ │ ├── CampaignGenerateListJob.ts │ │ │ ├── CampaignInteractJob.ts │ │ │ ├── CampaignService.ts │ │ │ ├── CampaignStateJob.ts │ │ │ ├── CampaignTriggerSendJob.ts │ │ │ ├── ProcessCampaignsJob.ts │ │ │ └── __tests__ │ │ │ │ └── CampaignService.spec.ts │ │ ├── client │ │ │ ├── Client.ts │ │ │ ├── ClientController.ts │ │ │ ├── EventPostJob.ts │ │ │ ├── SegmentController.ts │ │ │ └── __tests__ │ │ │ │ └── ClientController.spec.ts │ │ ├── config │ │ │ ├── channels.ts │ │ │ ├── controllers.ts │ │ │ ├── database.ts │ │ │ ├── env.ts │ │ │ ├── logger.ts │ │ │ ├── queue.ts │ │ │ ├── rateLimit.ts │ │ │ ├── redis.ts │ │ │ ├── scheduler.ts │ │ │ ├── stats.ts │ │ │ └── storage.ts │ │ ├── core │ │ │ ├── Lock.ts │ │ │ ├── Model.ts │ │ │ ├── aws.ts │ │ │ ├── errors.ts │ │ │ ├── searchParams.ts │ │ │ └── validate.ts │ │ ├── error │ │ │ ├── BugSnagProvider.ts │ │ │ ├── ErrorHandler.ts │ │ │ ├── ErrorHandlerProvider.ts │ │ │ ├── LoggerProvider.ts │ │ │ └── SentryProvider.ts │ │ ├── index.ts │ │ ├── jobs.ts │ │ ├── journey │ │ │ ├── Journey.ts │ │ │ ├── JourneyController.ts │ │ │ ├── JourneyDelayJob.ts │ │ │ ├── JourneyError.ts │ │ │ ├── JourneyProcessJob.ts │ │ │ ├── JourneyRepository.ts │ │ │ ├── JourneyService.ts │ │ │ ├── JourneyState.ts │ │ │ ├── JourneyStatsJob.ts │ │ │ ├── JourneyStep.ts │ │ │ ├── ScheduledEntranceJob.ts │ │ │ ├── ScheduledEntranceOrchestratorJob.ts │ │ │ ├── UpdateJourneysJob.ts │ │ │ └── __tests__ │ │ │ │ ├── JourneyService.spec.ts │ │ │ │ ├── JourneyStep.spec.ts │ │ │ │ ├── ScheduledEntranceJob.spec.ts │ │ │ │ └── helpers.ts │ │ ├── lists │ │ │ ├── List.ts │ │ │ ├── ListController.ts │ │ │ ├── ListEvaluateUserJob.ts │ │ │ ├── ListPopulateJob.ts │ │ │ ├── ListRefreshJob.ts │ │ │ ├── ListService.ts │ │ │ ├── ListStatsJob.ts │ │ │ ├── ProcessListsJob.ts │ │ │ ├── UserListMatchJob.ts │ │ │ └── __tests__ │ │ │ │ ├── ListService.spec.ts │ │ │ │ └── ListStatsJob.spec.ts │ │ ├── organizations │ │ │ ├── Organization.ts │ │ │ ├── OrganizationController.ts │ │ │ ├── OrganizationMiddleware.ts │ │ │ └── OrganizationService.ts │ │ ├── profile │ │ │ └── ProfileController.ts │ │ ├── projects │ │ │ ├── Locale.ts │ │ │ ├── Project.ts │ │ │ ├── ProjectAdminController.ts │ │ │ ├── ProjectAdminRepository.ts │ │ │ ├── ProjectAdmins.ts │ │ │ ├── ProjectApiKey.ts │ │ │ ├── ProjectApiKeyController.ts │ │ │ ├── ProjectController.ts │ │ │ ├── ProjectError.ts │ │ │ ├── ProjectLocaleController.ts │ │ │ ├── ProjectService.ts │ │ │ └── __tests__ │ │ │ │ └── ProjectTestHelpers.ts │ │ ├── providers │ │ │ ├── LoggerProvider.ts │ │ │ ├── MessageTrigger.ts │ │ │ ├── MessageTriggerService.ts │ │ │ ├── Provider.ts │ │ │ ├── ProviderController.ts │ │ │ ├── ProviderRepository.ts │ │ │ ├── ProviderService.ts │ │ │ ├── analytics │ │ │ │ ├── Analytics.ts │ │ │ │ ├── AnalyticsProvider.ts │ │ │ │ ├── MixpanelProvider.ts │ │ │ │ ├── PosthogProvider.ts │ │ │ │ ├── SegmentProvider.ts │ │ │ │ └── index.ts │ │ │ ├── email │ │ │ │ ├── Email.ts │ │ │ │ ├── EmailChannel.ts │ │ │ │ ├── EmailJob.ts │ │ │ │ ├── EmailProvider.ts │ │ │ │ ├── LoggerEmailProvider.ts │ │ │ │ ├── MailgunEmailProvider.ts │ │ │ │ ├── SESEmailProvider.ts │ │ │ │ ├── SMPTEmailProvider.ts │ │ │ │ ├── SendGridEmailProvider.ts │ │ │ │ └── index.ts │ │ │ ├── push │ │ │ │ ├── LocalPushProvider.ts │ │ │ │ ├── LoggerPushProvider.ts │ │ │ │ ├── Push.ts │ │ │ │ ├── PushChannel.ts │ │ │ │ ├── PushError.ts │ │ │ │ ├── PushJob.ts │ │ │ │ ├── PushProvider.ts │ │ │ │ └── index.ts │ │ │ ├── text │ │ │ │ ├── HttpSMSProvider.ts │ │ │ │ ├── LoggerTextProvider.ts │ │ │ │ ├── NexmoTextProvider.ts │ │ │ │ ├── PlivoTextProvider.ts │ │ │ │ ├── TelnyxTextProvider.ts │ │ │ │ ├── TextChannel.ts │ │ │ │ ├── TextError.ts │ │ │ │ ├── TextJob.ts │ │ │ │ ├── TextMessage.ts │ │ │ │ ├── TextProvider.ts │ │ │ │ ├── TwilioTextProvider.ts │ │ │ │ ├── __tests__ │ │ │ │ │ └── TextChannel.spec.ts │ │ │ │ └── index.ts │ │ │ └── webhook │ │ │ │ ├── LocalWebhookProvider.ts │ │ │ │ ├── LoggerWebhookProvider.ts │ │ │ │ ├── Webhook.ts │ │ │ │ ├── WebhookChannel.ts │ │ │ │ ├── WebhookJob.ts │ │ │ │ ├── WebhookProvider.ts │ │ │ │ └── index.ts │ │ ├── queue │ │ │ ├── Job.ts │ │ │ ├── MemoryQueueProvider.ts │ │ │ ├── Queue.ts │ │ │ ├── QueueProvider.ts │ │ │ ├── RedisQueueProvider.ts │ │ │ ├── SQSQueueProvider.ts │ │ │ └── index.ts │ │ ├── render │ │ │ ├── Helpers │ │ │ │ ├── Array.ts │ │ │ │ ├── Common.ts │ │ │ │ ├── Date.ts │ │ │ │ ├── Number.ts │ │ │ │ ├── String.ts │ │ │ │ ├── Url.ts │ │ │ │ └── Util.ts │ │ │ ├── LinkController.ts │ │ │ ├── LinkService.ts │ │ │ ├── Resource.ts │ │ │ ├── ResourceController.ts │ │ │ ├── ResourceService.ts │ │ │ ├── Template.ts │ │ │ ├── TemplateController.ts │ │ │ ├── TemplateService.ts │ │ │ ├── __tests__ │ │ │ │ ├── LinkService.spec.ts │ │ │ │ ├── Template.spec.ts │ │ │ │ └── __snapshots__ │ │ │ │ │ ├── LinkService.spec.ts.snap │ │ │ │ │ └── Template.spec.ts.snap │ │ │ └── index.ts │ │ ├── rules │ │ │ ├── ArrayRule.ts │ │ │ ├── BooleanRule.ts │ │ │ ├── DateRule.ts │ │ │ ├── NumberRule.ts │ │ │ ├── ProjectRulePath.ts │ │ │ ├── Rule.ts │ │ │ ├── RuleEngine.ts │ │ │ ├── RuleError.ts │ │ │ ├── RuleHelpers.ts │ │ │ ├── RuleService.ts │ │ │ ├── StringRule.ts │ │ │ ├── WrapperRule.ts │ │ │ └── __tests__ │ │ │ │ ├── RuleEngine.spec.ts │ │ │ │ └── RuleService.spec.ts │ │ ├── schema │ │ │ ├── UserSchemaService.ts │ │ │ ├── UserSchemaSyncJob.ts │ │ │ └── __tests__ │ │ │ │ └── UserSchemaService.spec.ts │ │ ├── storage │ │ │ ├── FileStream.ts │ │ │ ├── Image.ts │ │ │ ├── ImageController.ts │ │ │ ├── ImageService.ts │ │ │ ├── LocalStorageProvider.ts │ │ │ ├── S3StorageProvider.ts │ │ │ ├── Storage.ts │ │ │ ├── StorageError.ts │ │ │ ├── StorageProvider.ts │ │ │ └── index.ts │ │ ├── subscriptions │ │ │ ├── Subscription.ts │ │ │ ├── SubscriptionController.ts │ │ │ ├── SubscriptionError.ts │ │ │ └── SubscriptionService.ts │ │ ├── tags │ │ │ ├── Tag.ts │ │ │ ├── TagController.ts │ │ │ └── TagService.ts │ │ ├── users │ │ │ ├── User.ts │ │ │ ├── UserAliasJob.ts │ │ │ ├── UserController.ts │ │ │ ├── UserDeleteJob.ts │ │ │ ├── UserDeviceJob.ts │ │ │ ├── UserError.ts │ │ │ ├── UserEvent.ts │ │ │ ├── UserEventRepository.ts │ │ │ ├── UserImport.ts │ │ │ ├── UserPatchJob.ts │ │ │ ├── UserRepository.ts │ │ │ └── __tests__ │ │ │ │ └── UserRepository.spec.ts │ │ ├── utilities │ │ │ └── index.ts │ │ └── worker.ts │ ├── tests │ │ └── setup.ts │ └── tsconfig.json └── ui │ ├── .dockerignore │ ├── .env.example │ ├── .eslintrc.js │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── config-overrides.js │ ├── docker │ └── nginx │ │ └── conf.d │ │ └── default.conf │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── config.js │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── locales │ │ ├── en.json │ │ ├── es.json │ │ └── zh.json │ ├── manifest.json │ ├── robots.txt │ └── safari.svg │ ├── scripts │ └── env.sh │ ├── src │ ├── App.tsx │ ├── api.ts │ ├── assets │ │ ├── logo-icon.svg │ │ ├── logo.svg │ │ ├── parcelvoy.svg │ │ └── parcelvoylogo.png │ ├── config │ │ └── env.ts │ ├── contexts.ts │ ├── hooks.ts │ ├── i18n.ts │ ├── index.css │ ├── index.tsx │ ├── mod.ts │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupProxy.js │ ├── setupTests.ts │ ├── types.ts │ ├── ui │ │ ├── Alert.css │ │ ├── Alert.tsx │ │ ├── Button.css │ │ ├── Button.tsx │ │ ├── ButtonGroup.css │ │ ├── ButtonGroup.tsx │ │ ├── CodeExample.css │ │ ├── CodeExample.tsx │ │ ├── Columns.css │ │ ├── Columns.tsx │ │ ├── DataTable.css │ │ ├── DataTable.tsx │ │ ├── Dialog.tsx │ │ ├── Heading.css │ │ ├── Heading.tsx │ │ ├── Iframe.tsx │ │ ├── InfoTable.css │ │ ├── InfoTable.tsx │ │ ├── JsonPreview.css │ │ ├── JsonPreview.tsx │ │ ├── Menu.css │ │ ├── Menu.tsx │ │ ├── Modal.css │ │ ├── Modal.tsx │ │ ├── NavLink.tsx │ │ ├── PageContent.tsx │ │ ├── Pagination.css │ │ ├── Pagination.tsx │ │ ├── PreferencesContext.tsx │ │ ├── Preview.css │ │ ├── Preview.tsx │ │ ├── PreviewImage.css │ │ ├── PreviewImage.tsx │ │ ├── RRuleEditor.tsx │ │ ├── SearchTable.tsx │ │ ├── Sidebar.css │ │ ├── Sidebar.tsx │ │ ├── SourceEditor.css │ │ ├── SourceEditor.tsx │ │ ├── Stack.css │ │ ├── Stack.tsx │ │ ├── Tabs.css │ │ ├── Tabs.tsx │ │ ├── Tag.css │ │ ├── Tag.tsx │ │ ├── Text.tsx │ │ ├── Tile.css │ │ ├── Tile.tsx │ │ ├── Toast.css │ │ ├── Toast.tsx │ │ ├── ToastBar.tsx │ │ ├── ToastIcon.tsx │ │ ├── form │ │ │ ├── EntityIdPicker.tsx │ │ │ ├── Field.ts │ │ │ ├── FormWrapper.tsx │ │ │ ├── JsonField.tsx │ │ │ ├── MultiOptionField.tsx │ │ │ ├── MultiSelect.tsx │ │ │ ├── RadioInput.css │ │ │ ├── RadioInput.tsx │ │ │ ├── SchemaFields.css │ │ │ ├── SchemaFields.tsx │ │ │ ├── Select.css │ │ │ ├── SingleSelect.tsx │ │ │ ├── SwitchField.tsx │ │ │ ├── TextInput.css │ │ │ ├── TextInput.tsx │ │ │ ├── UploadField.css │ │ │ └── UploadField.tsx │ │ ├── icons.tsx │ │ ├── index.ts │ │ └── utils.tsx │ ├── utils.ts │ ├── variables.css │ └── views │ │ ├── Auth.tsx │ │ ├── ErrorPage.css │ │ ├── ErrorPage.tsx │ │ ├── LoaderContextProvider.tsx │ │ ├── auth │ │ ├── Auth.css │ │ ├── Login.tsx │ │ ├── Onboarding.tsx │ │ ├── OnboardingProject.tsx │ │ └── OnboardingStart.tsx │ │ ├── campaign │ │ ├── CampaignDelivery.tsx │ │ ├── CampaignDesign.tsx │ │ ├── CampaignDetail.tsx │ │ ├── CampaignForm.tsx │ │ ├── CampaignOverview.tsx │ │ ├── CampaignPreview.css │ │ ├── CampaignPreview.tsx │ │ ├── Campaigns.tsx │ │ ├── ChannelTag.tsx │ │ ├── ImageGalleryModal.css │ │ ├── ImageGalleryModal.tsx │ │ ├── LaunchCampaign.tsx │ │ ├── LocaleEditModal.tsx │ │ ├── LocaleSelector.tsx │ │ ├── ResourceFontModal.tsx │ │ ├── ResourceModal.tsx │ │ ├── TemplateCreateModal.tsx │ │ ├── TemplateDetail.tsx │ │ └── editor │ │ │ ├── EmailEditor.css │ │ │ ├── EmailEditor.tsx │ │ │ ├── HtmlEditor.tsx │ │ │ ├── VisualEditor.css │ │ │ └── VisualEditor.tsx │ │ ├── createStatefulRoute.tsx │ │ ├── journey │ │ ├── EntranceDetails.tsx │ │ ├── JourneyDetail.tsx │ │ ├── JourneyEditor.css │ │ ├── JourneyEditor.tsx │ │ ├── JourneyForm.tsx │ │ ├── JourneyUserEntrances.tsx │ │ ├── Journeys.tsx │ │ └── steps │ │ │ ├── Action.tsx │ │ │ ├── Balancer.tsx │ │ │ ├── Delay.tsx │ │ │ ├── Entrance.tsx │ │ │ ├── Event.tsx │ │ │ ├── Exit.tsx │ │ │ ├── Experiment.tsx │ │ │ ├── Gate.tsx │ │ │ ├── JourneyLink.tsx │ │ │ ├── Update.tsx │ │ │ └── index.ts │ │ ├── organization │ │ ├── Admins.tsx │ │ ├── Organization.tsx │ │ ├── Performance.css │ │ ├── Performance.tsx │ │ └── Settings.tsx │ │ ├── project │ │ ├── ProjectForm.tsx │ │ ├── ProjectRoleRequired.tsx │ │ ├── ProjectSidebar.tsx │ │ └── Projects.tsx │ │ ├── router.tsx │ │ ├── settings │ │ ├── ApiKeys.tsx │ │ ├── IntegrationModal.css │ │ ├── IntegrationModal.tsx │ │ ├── Integrations.tsx │ │ ├── Locales.tsx │ │ ├── ProjectSettings.tsx │ │ ├── Settings.tsx │ │ ├── Subscriptions.tsx │ │ ├── TagPicker.tsx │ │ ├── Tags.tsx │ │ ├── TeamInvite.tsx │ │ └── Teams.tsx │ │ └── users │ │ ├── ListCreateForm.tsx │ │ ├── ListDetail.tsx │ │ ├── ListTable.tsx │ │ ├── Lists.tsx │ │ ├── RuleBuilder.css │ │ ├── RuleBuilder.tsx │ │ ├── UserDetail.tsx │ │ ├── UserDetailAttrs.tsx │ │ ├── UserDetailEvents.tsx │ │ ├── UserDetailJourneys.tsx │ │ ├── UserDetailLists.tsx │ │ ├── UserDetailSubscriptions.tsx │ │ └── Users.tsx │ ├── tsconfig.json │ ├── tsconfig.lib-module.json │ └── tsconfig.lib.json ├── docker-compose.yml ├── docker └── Dockerfile.render ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── advanced │ │ ├── _category_.json │ │ ├── authentication.md │ │ ├── config.md │ │ ├── deeplinking.md │ │ └── storage.md │ ├── api │ │ ├── _category_.json │ │ ├── admin.md │ │ └── client.md │ ├── clients │ │ ├── _category_.json │ │ ├── android.md │ │ ├── ios.md │ │ └── javascript.md │ ├── deploy │ │ ├── _category_.json │ │ ├── aws-ec2.md │ │ ├── aws-elasticbeanstalk.md │ │ ├── deploy.md │ │ └── digitalocean.md │ ├── home.md │ ├── how-to │ │ ├── _category_.json │ │ ├── campaigns │ │ │ ├── _category_.json │ │ │ ├── create.md │ │ │ ├── design.md │ │ │ ├── index.md │ │ │ ├── launch.md │ │ │ └── templates.md │ │ ├── index.md │ │ ├── journeys │ │ │ ├── _category_.json │ │ │ ├── data.md │ │ │ ├── examples │ │ │ │ ├── _category_.json │ │ │ │ ├── exitCriteria.md │ │ │ │ ├── recurringReminder.md │ │ │ │ ├── reservations.md │ │ │ │ ├── weeklySummary.md │ │ │ │ └── welcome.md │ │ │ ├── index.md │ │ │ └── types.md │ │ ├── lists.md │ │ ├── settings │ │ │ ├── _category_.json │ │ │ ├── apikeys.md │ │ │ ├── index.md │ │ │ ├── providers.md │ │ │ └── subscriptions.md │ │ └── users.md │ ├── overview │ │ ├── _category_.json │ │ ├── introduction.md │ │ └── quick-start.md │ └── providers │ │ ├── _category_.json │ │ ├── index.md │ │ ├── mailgun.md │ │ ├── notification.md │ │ ├── plivo.md │ │ ├── posthog.md │ │ ├── segment.md │ │ ├── sendgrid.md │ │ ├── ses.md │ │ ├── smtp.md │ │ ├── telnyx.md │ │ ├── twilio.md │ │ └── vonage.md ├── docusaurus.config.js ├── package-lock.json ├── package.json ├── sidebars.js ├── src │ ├── components │ │ ├── Card.css │ │ ├── Card.js │ │ ├── Cards.js │ │ └── Providers.js │ ├── css │ │ └── custom.css │ └── pages │ │ └── markdown-page.md ├── static │ ├── .nojekyll │ ├── img │ │ ├── api_keys_create_modal.png │ │ ├── campaigns_create.png │ │ ├── campaigns_create_modal.png │ │ ├── campaigns_design.png │ │ ├── campaigns_design_code.png │ │ ├── campaigns_design_push.png │ │ ├── campaigns_design_template.png │ │ ├── campaigns_design_text.png │ │ ├── campaigns_design_visual.png │ │ ├── campaigns_launch.png │ │ ├── campaigns_launch_modal.png │ │ ├── campaigns_launch_schedule.png │ │ ├── campaigns_overview.png │ │ ├── favicon.ico │ │ ├── journeys_airline.png │ │ ├── journeys_data_key.png │ │ ├── journeys_data_key_campaigns.png │ │ ├── journeys_data_key_gates.png │ │ ├── journeys_data_key_user_updates.png │ │ ├── journeys_example_exit.png │ │ ├── journeys_example_reminder.png │ │ ├── journeys_example_weekly_stats_email.png │ │ ├── journeys_example_weekly_stats_webhook.png │ │ ├── journeys_example_weekly_summary.png │ │ ├── journeys_example_welcome.png │ │ ├── journeys_gate.png │ │ ├── journeys_overview.png │ │ ├── logo.svg │ │ ├── og-image.jpg │ │ ├── parcelvoy-light.svg │ │ └── parcelvoy.svg │ └── robots.txt └── tsconfig.json ├── lerna.json ├── package-lock.json ├── package.json ├── render.yaml └── tsconfig.base.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .DS_Store 3 | .env 4 | node_modules 5 | **/node_modules 6 | build -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_SECRET=Ck7Rt1uiy5WGbrTFj1HBjymbBoA6zqih 2 | BASE_URL=/ 3 | UI_PORT=3000 4 | NODE_ENV=production 5 | 6 | DB_CLIENT=mysql2 7 | DB_HOST=mysql 8 | DB_USERNAME=root 9 | DB_PASSWORD=parcelvoypassword 10 | DB_PORT=3306 11 | DB_DATABASE=parcelvoy 12 | 13 | QUEUE_DRIVER=redis 14 | REDIS_HOST=redis 15 | REDIS_PORT=6379 16 | 17 | STORAGE_DRIVER=local 18 | STORAGE_BASE_URL=http://localhost:3000/uploads 19 | 20 | AUTH_DRIVER=basic 21 | AUTH_BASIC_EMAIL=test@parcelvoy.com 22 | AUTH_BASIC_PASSWORD=password 23 | AUTH_BASIC_NAME=Login 24 | -------------------------------------------------------------------------------- /.github/assets/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parcelvoy/platform/3d59d28fff26471b3c3a76aa119290bd3d314ab0/.github/assets/logo-dark.png -------------------------------------------------------------------------------- /.github/assets/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parcelvoy/platform/3d59d28fff26471b3c3a76aa119290bd3d314ab0/.github/assets/logo-light.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | "./apps/ui", 4 | "./apps/platform" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Parcelvoy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/platform/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .DS_Store 3 | .env 4 | node_modules 5 | build -------------------------------------------------------------------------------- /apps/platform/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3001 3 | APP_SECRET= 4 | LOG_LEVEL=info 5 | LOG_PRETTY_PRINT=true 6 | 7 | DB_HOST=127.0.0.1 8 | DB_USERNAME=parcelvoy 9 | DB_PASSWORD= 10 | DB_PORT=3306 11 | DB_DATABASE=parcelvoy 12 | 13 | QUEUE_DRIVER=redis 14 | REDIS_HOST=127.0.0.1 15 | REDIS_PORT=6379 16 | 17 | STORAGE_DRIVER=local 18 | STORAGE_BASE_URL=http://localhost:3001/uploads 19 | 20 | AUTH_DRIVER=basic 21 | AUTH_BASIC_EMAIL=test@parcelvoy.com 22 | AUTH_BASIC_PASSWORD=password 23 | AUTH_BASIC_NAME=Login 24 | -------------------------------------------------------------------------------- /apps/platform/@types/knex.d.ts: -------------------------------------------------------------------------------- 1 | import { Knex as KnexOriginal } from 'knex' 2 | declare module 'knex' { 3 | namespace Knex { 4 | interface QueryInterface { 5 | when( 6 | condition: boolean, 7 | fnif: (builder: QueryBuilder) => QueryBuilder, 8 | fnelse?: (builder: QueryBuilder) => QueryBuilder, 9 | ): KnexOriginal.QueryBuilder 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/platform/Dockerfile: -------------------------------------------------------------------------------- 1 | # --------------> The compiler image 2 | FROM node:18 AS compile 3 | WORKDIR /usr/src/app/apps/platform 4 | COPY ./tsconfig.base.json /usr/src/app 5 | COPY ./apps/platform ./ 6 | RUN npm ci 7 | RUN npm run build 8 | 9 | # --------------> The build image 10 | FROM node:18 AS build 11 | WORKDIR /usr/src/app 12 | COPY --from=compile /usr/src/app/apps/platform/package*.json ./ 13 | COPY --from=compile /usr/src/app/apps/platform/build ./build 14 | COPY --from=compile /usr/src/app/apps/platform/db ./db 15 | COPY --from=compile /usr/src/app/apps/platform/public ./public 16 | RUN npm ci --only=production 17 | 18 | # --------------> The production image 19 | FROM node:18-alpine 20 | RUN apk add dumb-init 21 | ENV NODE_ENV="production" 22 | USER node 23 | WORKDIR /usr/src/app 24 | COPY --chown=node:node --from=build /usr/src/app ./ 25 | EXPOSE 3001 26 | CMD ["dumb-init", "node", "build/boot.js"] 27 | -------------------------------------------------------------------------------- /apps/platform/db/migration.stub: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | 3 | } 4 | 5 | exports.down = async function(knex) { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20220823000825_add_providers_table.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .createTable('providers', function(table) { 4 | table.increments() 5 | table.integer('project_id') 6 | .unsigned() 7 | .notNullable() 8 | .references('id') 9 | .inTable('projects') 10 | .onDelete('CASCADE') 11 | table.string('external_id').index() 12 | table.string('name', 255).defaultTo('') 13 | table.string('group', 255).notNullable() 14 | table.json('data') 15 | table.boolean('is_default').defaultTo(0) 16 | table.timestamp('created_at').defaultTo(knex.fn.now()) 17 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 18 | }) 19 | } 20 | 21 | exports.down = function(knex) { 22 | knex.schema 23 | .dropTable('providers') 24 | } 25 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20220920022913_add_media_library.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .createTable('images', function(table) { 4 | table.increments() 5 | table.integer('project_id') 6 | .unsigned() 7 | .notNullable() 8 | .references('id') 9 | .inTable('projects') 10 | .onDelete('CASCADE') 11 | table.string('uuid', 255).notNullable() 12 | table.string('name', 255).defaultTo('') 13 | table.string('original_name') 14 | table.string('extension') 15 | table.string('alt') 16 | table.integer('file_size') 17 | table.timestamp('created_at').defaultTo(knex.fn.now()) 18 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 19 | }) 20 | } 21 | 22 | exports.down = function(knex) { 23 | return knex.schema.dropTable('images') 24 | } 25 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20221105203054_add_refresh_tokens.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .createTable('refresh_tokens', function(table) { 4 | table.increments() 5 | table.integer('admin_id') 6 | .unsigned() 7 | .notNullable() 8 | .references('id') 9 | .inTable('admins') 10 | .onDelete('CASCADE') 11 | table.string('token', 255).notNullable().index() 12 | table.boolean('revoked').defaultsTo(false) 13 | table.timestamp('expires_at') 14 | table.timestamp('created_at').defaultTo(knex.fn.now()) 15 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 16 | }) 17 | } 18 | 19 | exports.down = function(knex) { 20 | return knex.schema.dropTable('refresh_tokens') 21 | } 22 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20221118223317_stored_token_rename.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.renameTable('refresh_tokens', 'access_tokens') 3 | await knex.schema.alterTable('access_tokens', function(table) { 4 | table.string('ip') 5 | table.string('user_agent') 6 | }) 7 | } 8 | 9 | exports.down = function(knex) { 10 | return knex.schema.dropTable('revoked_access_tokens') 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20221122013145_add_campaign_fields.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .table('campaigns', function(table) { 4 | table.timestamp('send_at').after('subscription_id') 5 | table.json('delivery').after('subscription_id') 6 | table.string('state', 20).after('subscription_id') 7 | }) 8 | } 9 | 10 | exports.down = function(knex) { 11 | return knex.schema 12 | .table('campaigns', function(table) { 13 | table.dropColumn('send_at') 14 | table.dropColumn('delivery') 15 | table.dropColumn('state') 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20221224170020_list_modifications.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('user_list', function(table) { 3 | table.unique(['user_id', 'list_id']) 4 | }) 5 | await knex.schema.table('lists', function(table) { 6 | table.renameColumn('rules', 'rule') 7 | table.string('state', 25).after('name') 8 | table.string('type', 25).after('name') 9 | }) 10 | } 11 | 12 | exports.down = async function(knex) { 13 | await knex.schema.table('user_list', function(table) { 14 | table.dropUnique(['user_id', 'list_id']) 15 | }) 16 | await knex.schema.table('lists', function(table) { 17 | table.renameColumn('rule', 'rules') 18 | table.dropColumn('state') 19 | table.dropColumn('type') 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230204200316_step_type_x_y_float.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .alterTable('journey_steps', function(table) { 4 | table.float('x').notNullable().defaultTo(0).alter() 5 | table.float('y').notNullable().defaultTo(0).alter() 6 | }) 7 | } 8 | 9 | exports.down = function(knex) { 10 | return knex.schema 11 | .alterTable('journey_steps', function(table) { 12 | table.integer('x').notNullable().defaultTo(0).alter() 13 | table.integer('y').notNullable().defaultTo(0).alter() 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230210024314_add_project_timezone.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .table('projects', function(table) { 4 | table.string('timezone', 50).after('locale') 5 | }) 6 | } 7 | 8 | exports.down = function(knex) { 9 | return knex.schema 10 | .table('projects', function(table) { 11 | table.dropColumn('timezone') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230212215653_rename_journey_steps_uuid.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.alterTable('journey_steps', function(table) { 3 | table.renameColumn('uuid', 'external_id') 4 | }).then(() => knex.schema.alterTable('journey_steps', function(table) { 5 | table.string('external_id', 128).alter() 6 | })) 7 | } 8 | 9 | exports.down = function(knex) { 10 | return knex.schema.alterTable('journey_steps', function(table) { 11 | table.renameColumn('external_id', 'uuid') 12 | }).then(() => knex.schema.alterTable('journey_steps', function(table) { 13 | table.uuid('uuid').alter() 14 | })) 15 | } 16 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230226042803_add_user_list_version.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('user_list', function(table) { 3 | table.integer('version').after('event_id').defaultTo(0).index() 4 | }) 5 | await knex.schema.table('lists', function(table) { 6 | table.integer('version').after('users_count').defaultTo(0) 7 | }) 8 | } 9 | 10 | exports.down = async function(knex) { 11 | await knex.schema.table('user_list', function(table) { 12 | table.dropColumn('version') 13 | }) 14 | await knex.schema.table('lists', function(table) { 15 | table.dropColumn('version') 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230303141811_add_schedule_lock.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.createTable('job_locks', function(table) { 3 | table.increments() 4 | table.string('key', 255).unique() 5 | table.string('owner', 255) 6 | table.timestamp('expiration') 7 | table.timestamp('created_at').defaultTo(knex.fn.now()) 8 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 9 | }) 10 | } 11 | 12 | exports.down = async function(knex) { 13 | await knex.schema.dropTable('job_locks') 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230306220639_add_journey_step_child_priority.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .alterTable('journey_step_child', function(table) { 4 | table.integer('priority') 5 | .notNullable() 6 | .defaultTo(0) 7 | }) 8 | } 9 | 10 | exports.down = function(knex) { 11 | return knex.schema 12 | .alterTable('journey_step_child', function(table) { 13 | table.dropColumn('priority') 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230317133712_update_campaign_multiple_lists.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('campaigns', function(table) { 3 | table.json('exclusion_list_ids').after('list_id') 4 | table.json('list_ids').after('list_id') 5 | }) 6 | await knex.raw('UPDATE campaigns SET list_ids = CONCAT("[", campaigns.list_id, "]")') 7 | await knex.schema.table('campaigns', function(table) { 8 | table.dropForeign('list_id') 9 | table.dropColumn('list_id') 10 | }) 11 | } 12 | 13 | exports.down = async function(knex) { 14 | await knex.schema.table('campaigns', function(table) { 15 | table.integer('list_id') 16 | .unsigned() 17 | .references('id') 18 | .inTable('lists') 19 | .onDelete('CASCADE') 20 | .after('project_id') 21 | table.dropColumn('list_ids') 22 | table.dropColumn('exclusion_list_ids') 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230319192319_add_project_roles.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema 3 | .alterTable('project_admins', function(table) { 4 | table.string('role', 64).notNullable().defaultTo('support') 5 | }) 6 | .alterTable('project_api_keys', function(table) { 7 | table.string('role', 64).notNullable().defaultTo('support') 8 | }) 9 | } 10 | 11 | exports.down = function(knex) { 12 | return knex.schema 13 | .alterTable('project_admins', function(table) { 14 | table.dropColumn('role') 15 | }) 16 | .alterTable('project_api_keys', function(table) { 17 | table.dropColumn('role') 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230326145047_drop_external_id_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema 3 | .alterTable('providers', function(table) { 4 | table.dropIndex('external_id') 5 | table.dropColumn('external_id') 6 | }) 7 | } 8 | 9 | exports.down = async function(knex) { 10 | await knex.schema 11 | .alterTable('providers', function(table) { 12 | table.string('external_id').index() 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230331203356_improve_user_indexes.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema 3 | .alterTable('users', function(table) { 4 | table.index('external_id') 5 | table.index('anonymous_id') 6 | table.index('project_id') 7 | }) 8 | } 9 | 10 | exports.down = async function(knex) { 11 | await knex.schema 12 | .alterTable('users', function(table) { 13 | table.dropIndex('external_id') 14 | table.dropIndex('anonymous_id') 15 | table.dropIndex('project_id') 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230401212338_add_user_locale_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema 3 | .table('users', function(table) { 4 | table.string('locale').after('timezone') 5 | }) 6 | } 7 | 8 | exports.down = async function(knex) { 9 | await knex.schema 10 | .table('users', function(table) { 11 | table.dropColumn('locale') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230403152432_add_admin_profile_image.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('admins', function(table) { 3 | table.string('image_url', 255).after('email') 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('admins', function(table) { 9 | table.dropColumn('image_url') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230407162219_add_campaign_stats.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('campaign_sends', function(table) { 3 | table.integer('clicks').defaultTo(0).after('send_at') 4 | table.timestamp('opened_at').nullable().after('send_at') 5 | }) 6 | } 7 | 8 | exports.down = async function(knex) { 9 | await knex.schema.table('campaign_sends', function(table) { 10 | table.dropColumn('clicks') 11 | table.dropColumn('opened_at') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230418041529_add_provider_rate_limiter.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('providers', function(table) { 3 | table.integer('rate_limit').nullable().after('is_default') 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('providers', function(table) { 9 | table.dropColumn('rate_limit') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230419032608_users_allow_null_external_id.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('users', function(table) { 3 | table.string('external_id', 255).nullable().alter() 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('users', function(table) { 9 | table.string('external_id', 255).notNullable().alter() 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230427012022_add_list_soft_delete.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('lists', function(table) { 3 | table.timestamp('deleted_at').nullable() 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('lists', function(table) { 9 | table.dropColumn('deleted_at') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230504131759_add_user_indexes.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('users', function(table) { 3 | table.index('email') 4 | table.index('phone') 5 | }) 6 | } 7 | 8 | exports.down = async function(knex) { 9 | await knex.schema.table('users', function(table) { 10 | table.dropIndex('email') 11 | table.dropIndex('phone') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230505020951_add_project_rule_paths.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.createTable('project_rule_paths', function(table) { 3 | table.increments() 4 | table.integer('project_id') 5 | .unsigned() 6 | .notNullable() 7 | .references('id') 8 | .inTable('projects') 9 | .onDelete('CASCADE') 10 | table.string('path').notNullable() 11 | table.string('name').nullable() 12 | table.string('type', 50).notNullable() // 'user' | 'event' 13 | table.timestamp('created_at').defaultTo(knex.fn.now()) 14 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 15 | }) 16 | } 17 | 18 | exports.down = async function(knex) { 19 | await knex.schema.dropTable('project_rule_paths') 20 | } 21 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230516214727_add_campaign_type.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('campaigns', function(table) { 3 | table.string('type', 255).after('id') 4 | }) 5 | await knex.raw('UPDATE campaigns SET type = IF(list_ids IS NULL, "trigger", "blast")') 6 | } 7 | 8 | exports.down = async function(knex) { 9 | await knex.schema.table('campaigns', function(table) { 10 | table.dropColumn('type') 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230602115106_journey_add_published.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('journeys', function(table) { 3 | table.boolean('published') 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.alterTable('journeys', function(table) { 9 | table.dropColumn('published') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230603152941_admins_last_name_nullable.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('admins', function(table) { 3 | table.string('last_name', 255).nullable().alter() 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('admins', function(table) { 9 | table.string('last_name', 255).notNullable().alter() 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230624135258_project_sms_opt_out.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('projects', function(table) { 3 | table.string('text_opt_out_message', 255) 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('projects', function(table) { 9 | table.dropColumn('text_opt_out_message') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230626205637_add_user_event_index.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('user_events', function(table) { 3 | table.index(['name', 'user_id']) 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('user_events', function(table) { 9 | table.dropIndex(['name', 'user_id']) 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230627184626_journey_step_gate_migration.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const crypto = require('crypto') 3 | 4 | exports.up = async function(knex) { 5 | await knex.schema.table('lists', function(table) { 6 | table.boolean('is_visible').defaultTo(true) 7 | }) 8 | 9 | // Update all existing journey steps to have a list_id 10 | const gates = await knex('journey_steps').where('type', 'gate') 11 | for (const gate of gates) { 12 | const journey = await knex('journeys').where('id', gate.journey_id).first() 13 | const [id] = await knex('lists').insert({ 14 | project_id: journey.project_id, 15 | name: crypto.randomUUID(), 16 | type: 'dynamic', 17 | state: 'ready', 18 | rule: JSON.stringify(gate.rule), 19 | version: 0, 20 | is_visible: false, 21 | users_count: 0, 22 | }) 23 | await knex('journey_steps').update({ data: JSON.stringify({ list_id: id }) }).where('id', gate.id) 24 | } 25 | } 26 | 27 | exports.down = async function(knex) { 28 | await knex.schema.table('lists', function(table) { 29 | table.dropColumn('is_visible') 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230803030205_add_settings_to_org.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('projects', function(table) { 3 | table.boolean('link_wrap').defaultTo(0) 4 | }) 5 | 6 | if (process.env.TRACKING_LINK_WRAP) { 7 | await knex('projects').update({ link_wrap: process.env.TRACKING_LINK_WRAP === 'true' }) 8 | } 9 | 10 | await knex.schema.alterTable('organizations', function(table) { 11 | table.string('tracking_deeplink_mirror_url', 255) 12 | table.integer('notification_provider_id') 13 | .references('id') 14 | .inTable('providers') 15 | .onDelete('SET NULL') 16 | .nullable() 17 | .unsigned() 18 | }) 19 | 20 | if (process.env.TRACKING_DEEPLINK_MIRROR_URL) { 21 | await knex('organizations').update({ tracking_deeplink_mirror_url: process.env.TRACKING_DEEPLINK_MIRROR_URL }) 22 | } 23 | } 24 | 25 | exports.down = async function(knex) { 26 | await knex.schema.alterTable('projects', function(table) { 27 | table.dropColumn('link_wrap') 28 | }) 29 | await knex.schema.alterTable('organizations', function(table) { 30 | table.dropColumn('tracking_deeplink_mirror_url') 31 | table.dropColumn('notification_provider_id') 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230814014024_add_locales.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.createTable('locales', function(table) { 3 | table.increments() 4 | table.integer('project_id') 5 | .unsigned() 6 | .notNullable() 7 | .references('id') 8 | .inTable('projects') 9 | .onDelete('CASCADE') 10 | table.string('key') 11 | table.string('label') 12 | table.timestamp('created_at').defaultTo(knex.fn.now()) 13 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 14 | }) 15 | } 16 | 17 | exports.down = async function(knex) { 18 | await knex.schema.dropTable('locales') 19 | } 20 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230827174518_add_user_events_date_index.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('user_events', function(table) { 3 | table.index('created_at') 4 | }) 5 | await knex.schema.table('users', function(table) { 6 | table.index('updated_at') 7 | }) 8 | } 9 | 10 | exports.down = async function(knex) { 11 | await knex.schema.table('user_events', function(table) { 12 | table.dropIndex('created_at') 13 | }) 14 | await knex.schema.table('users', function(table) { 15 | table.dropIndex('updated_at') 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230828003728_add_user_list_index.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('user_list', function(table) { 3 | table.index('created_at') 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('user_list', function(table) { 9 | table.dropIndex('created_at') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230910182600_add_journey_stats.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema 3 | .alterTable('journeys', function(table) { 4 | table.json('stats') 5 | table.timestamp('stats_at') 6 | }) 7 | } 8 | 9 | exports.down = async function(knex) { 10 | await knex.schema 11 | .alterTable('journeys', function(table) { 12 | table.dropColumn('stats') 13 | table.dropColumn('stats_at') 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20230919110256_add_journey_step_name.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('journey_steps', function(table) { 3 | table.string('name', 128) 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.alterTable('journey_steps', function(table) { 9 | table.dropColumn('name') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20231008171139_update_admin_invite.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('admins', function(table) { 3 | table.string('first_name') 4 | .nullable() 5 | .alter() 6 | table.string('last_name') 7 | .nullable() 8 | .alter() 9 | }) 10 | } 11 | 12 | exports.down = async function(knex) { 13 | await knex.schema.alterTable('admins', function(table) { 14 | table.string('first_name') 15 | .notNullable() 16 | .alter() 17 | table.string('last_name') 18 | .notNullable() 19 | .alter() 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20231013222057_add_journey_user_step_ref_index.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema 3 | .alterTable('journey_user_step', function(table) { 4 | table.index('ref') 5 | }) 6 | .alterTable('journey_steps', function(table) { 7 | table.timestamp('next_scheduled_at').nullable() 8 | }) 9 | } 10 | 11 | exports.down = async function(knex) { 12 | await knex.schema 13 | .alterTable('journey_user_step', function(table) { 14 | table.dropIndex('ref') 15 | }) 16 | .alterTable('journey_steps', function(table) { 17 | table.dropColumn('next_scheduled_at') 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20231203145249_change_journey_entrance_case.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.raw(` 3 | UPDATE journey_steps 4 | SET data = JSON_SET(data, '$.event_name', data->>'$.eventName'), 5 | data = JSON_REMOVE(data, '$.eventName') 6 | WHERE \`type\` = 'entrance' AND data->>'$.trigger' = 'event' 7 | `) 8 | } 9 | 10 | exports.down = async function(knex) { 11 | await knex.raw(` 12 | UPDATE journey_steps 13 | SET data = JSON_SET(data, '$.eventName', data->>'$.event_name'), 14 | data = JSON_REMOVE(data, '$.event_name') 15 | WHERE \`type\` = 'entrance' AND data->>'$.trigger' = 'event' 16 | `) 17 | } 18 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20240310020733_add_admin_organization_role.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema 3 | .alterTable('admins', function(table) { 4 | table.string('role', 64).notNullable().defaultTo('member') 5 | }) 6 | await knex('admins').update({ role: 'owner' }) 7 | } 8 | 9 | exports.down = async function(knex) { 10 | await knex.schema 11 | .alterTable('admins', function(table) { 12 | table.dropColumn('role') 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20240323145423_add_journey_step_child_key.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('journey_step_child', function(table) { 3 | table.string('path', 128) 4 | }) 5 | await knex.raw(` 6 | update journey_step_child sc 7 | inner join journey_steps s on sc.step_id = s.id 8 | set sc.path = 'yes' 9 | where s.type = 'gate' and priority = 0; 10 | `) 11 | await knex.raw(` 12 | update journey_step_child sc 13 | inner join journey_steps s on sc.step_id = s.id 14 | set sc.path = 'no' 15 | where s.type = 'gate' and priority > 0; 16 | `) 17 | } 18 | 19 | exports.down = async function(knex) { 20 | await knex.schema.alterTable('journey_step_child', function(table) { 21 | table.dropColumn('path') 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20240419015246_add_project_text_help.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('projects', function(table) { 3 | table.string('text_help_message', 255) 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('projects', function(table) { 9 | table.dropColumn('text_help_message') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20240808205738_add_provider_rate_interval.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('providers', function(table) { 3 | table.string('rate_interval', 12).defaultTo('second').after('rate_limit') 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('providers', function(table) { 9 | table.dropColumn('rate_interval') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20240914230319_add_resources.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.createTable('resources', function(table) { 3 | table.increments() 4 | table.integer('project_id') 5 | .unsigned() 6 | .notNullable() 7 | .references('id') 8 | .inTable('projects') 9 | .onDelete('CASCADE') 10 | table.string('type') 11 | table.string('name') 12 | table.json('value') 13 | table.timestamp('created_at').defaultTo(knex.fn.now()) 14 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 15 | }) 16 | } 17 | 18 | exports.down = async function(knex) { 19 | await knex.schema.dropTable('resources') 20 | } 21 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20241012155809_add_push_link_wrapping.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('projects', function(table) { 3 | table.renameColumn('link_wrap', 'link_wrap_email') 4 | table.boolean('link_wrap_push').defaultTo(0) 5 | }) 6 | } 7 | 8 | exports.down = async function(knex) { 9 | await knex.schema.table('projects', function(table) { 10 | table.renameColumn('link_wrap_email', 'link_wrap') 11 | table.dropColumn('link_wrap_push') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20241017002221_reset_list_totals.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex('lists').update({ users_count: null }) 3 | } 4 | 5 | exports.down = async function(knex) { 6 | await knex('lists').update({ users_count: null }) 7 | } 8 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20241109235323_add_list_refreshed_at.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('lists', function(table) { 3 | table.timestamp('refreshed_at') 4 | }) 5 | 6 | const lists = await knex('rules') 7 | .leftJoin('rules as parent_rules', 'rules.root_uuid', 'parent_rules.uuid') 8 | .leftJoin('lists', 'lists.rule_id', 'parent_rules.id') 9 | .where('rules.type', 'date') 10 | .where('rules.value', 'LIKE', '%now%') 11 | .where('rules.value', 'LIKE', '%{{%') 12 | .groupBy('lists.id') 13 | .select('lists.id') 14 | await knex('lists') 15 | .update('refreshed_at', knex.raw('NOW()')) 16 | .whereIn('id', lists.map(list => list.id)) 17 | } 18 | 19 | exports.down = async function(knex) { 20 | await knex.schema.table('lists', function(table) { 21 | table.dropColumn('refreshed_at') 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20241119174518_add_journey_user_step_timestamp_index.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('journey_user_step', function(table) { 3 | table.index(['type', 'delay_until']) 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.alterTable('journey_user_step', function(table) { 9 | table.dropIndex(['type', 'delay_until']) 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20241228214210_modify_journey_user_step_indexes.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('journey_user_step', function(table) { 3 | table.index(['journey_id', 'type', 'delay_until']) 4 | table.dropIndex(['type', 'delay_until']) 5 | }) 6 | } 7 | 8 | exports.down = async function(knex) { 9 | await knex.schema.alterTable('journey_user_step', function(table) { 10 | table.index(['type', 'delay_until']) 11 | table.dropIndex(['journey_id', 'type', 'delay_until']) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20250307055453_update_campaign_send_primary_keys.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable('campaign_sends', table => { 3 | table.integer('id').alter() 4 | }) 5 | 6 | await knex.raw('alter table campaign_sends drop primary key') 7 | 8 | await knex.schema.alterTable('campaign_sends', table => { 9 | table.dropColumn('id') 10 | }) 11 | 12 | await knex.raw('alter table campaign_sends add primary key (campaign_id, user_id, reference_id)') 13 | 14 | await knex.schema.alterTable('campaign_sends', table => { 15 | table.index('user_id', 'campaign_sends_user_id_foreign') 16 | table.dropUnique(['user_id', 'campaign_id', 'reference_id']) 17 | }) 18 | } 19 | 20 | exports.down = async function(knex) { 21 | await knex.schema.alterTable('campaign_sends', table => { 22 | table.unique(['user_id', 'campaign_id', 'reference_id']) 23 | }) 24 | 25 | await knex.raw('alter table campaign_sends drop primary key') 26 | 27 | await knex.schema.alterTable('campaign_sends', table => { 28 | table.increments('id') 29 | }) 30 | 31 | await knex.raw('alter table campaign_sends add primary key (id)') 32 | } 33 | -------------------------------------------------------------------------------- /apps/platform/db/migrations/20250308062039_add_provider_soft_delete.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.table('providers', function(table) { 3 | table.timestamp('deleted_at').nullable() 4 | }) 5 | } 6 | 7 | exports.down = async function(knex) { 8 | await knex.schema.table('providers', function(table) { 9 | table.dropColumn('deleted_at') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'handlebars-utils' { 2 | export function value(val: any, context: any, options: any) 3 | } 4 | -------------------------------------------------------------------------------- /apps/platform/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | setupFilesAfterEnv: ['./tests/setup.ts'], 5 | testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], 6 | testPathIgnorePatterns: [ 7 | '/node_modules/', 8 | '/build', 9 | ], 10 | globals: { 11 | 'ts-jest': { 12 | isolatedModules: true, 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /apps/platform/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["**/*.test.ts", "**/*.spec.ts", "node_modules"], 3 | "watch": ["src", ".env"], 4 | "exec": "TZ=utc ts-node --transpile-only ./src/boot.ts", 5 | "ext": "ts,js,json" 6 | } -------------------------------------------------------------------------------- /apps/platform/public/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parcelvoy/platform/3d59d28fff26471b3c3a76aa119290bd3d314ab0/apps/platform/public/uploads/.gitkeep -------------------------------------------------------------------------------- /apps/platform/scripts/create-migration.mjs: -------------------------------------------------------------------------------- 1 | import knex from 'knex' 2 | 3 | const connection = knex({ 4 | client: process.env.DB_CLIENT ?? 'mysql2', 5 | connection: { 6 | host: process.env.DB_HOST, 7 | user: process.env.DB_USERNAME, 8 | password: process.env.DB_PASSWORD, 9 | port: process.env.DB_PORT, 10 | database: process.env.DB_DATABASE, 11 | }, 12 | }) 13 | 14 | const migrationConfig = { 15 | directory: './db/migrations', 16 | tableName: 'migrations', 17 | stub: './db/migration.stub', 18 | } 19 | 20 | const name = process.argv[2] 21 | if (!name) { 22 | console.log('migration: please include a name for migration') 23 | process.exit(9) 24 | } 25 | 26 | connection.migrate.make(name, migrationConfig) 27 | .then(() => { 28 | console.log('migration create finished') 29 | process.exit() 30 | }) 31 | .catch((err) => { 32 | console.error('migration create failed') 33 | console.error(err) 34 | process.exit(1) 35 | }) 36 | -------------------------------------------------------------------------------- /apps/platform/src/auth/AccessToken.ts: -------------------------------------------------------------------------------- 1 | import Model from '../core/Model' 2 | 3 | export class AccessToken extends Model { 4 | admin_id!: number 5 | expires_at!: Date 6 | token!: string 7 | revoked!: boolean 8 | ip!: string 9 | user_agent!: string 10 | } 11 | -------------------------------------------------------------------------------- /apps/platform/src/auth/Admin.ts: -------------------------------------------------------------------------------- 1 | import { OrganizationRole } from '../organizations/Organization' 2 | import Model, { ModelParams } from '../core/Model' 3 | 4 | export default class Admin extends Model { 5 | organization_id!: number 6 | email!: string 7 | first_name?: string 8 | last_name?: string 9 | image_url?: string 10 | role!: OrganizationRole 11 | } 12 | 13 | export type AdminParams = Omit & { domain?: string } 14 | 15 | export type AuthAdminParams = Omit & { domain?: string } 16 | -------------------------------------------------------------------------------- /apps/platform/src/auth/AdminRepository.ts: -------------------------------------------------------------------------------- 1 | import { PageParams } from '../core/searchParams' 2 | import Admin, { AdminParams } from './Admin' 3 | 4 | export const pagedAdmins = async (organizationId: number, params: PageParams) => { 5 | return await Admin.search( 6 | { ...params, fields: ['first_name', 'last_name', 'email'] }, 7 | qb => qb.where('organization_id', organizationId), 8 | ) 9 | } 10 | 11 | export const getAdmin = async (id: number, organizationId: number): Promise => { 12 | return await Admin.find(id, qb => qb.where('organization_id', organizationId)) 13 | } 14 | 15 | export const getAdminByEmail = async (email: string): Promise => { 16 | return await Admin.first(qb => qb.where('email', email)) 17 | } 18 | 19 | export const createOrUpdateAdmin = async ({ organization_id, ...params }: AdminParams): Promise => { 20 | const admin = await getAdminByEmail(params.email) 21 | 22 | if (admin?.id) { 23 | return Admin.updateAndFetch(admin.id, params) 24 | } else { 25 | return Admin.insertAndFetch({ 26 | ...params, 27 | organization_id, 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/platform/src/auth/GoogleAuthProvider.ts: -------------------------------------------------------------------------------- 1 | import { AuthTypeConfig } from './Auth' 2 | import AuthProvider, { AuthContext } from './AuthProvider' 3 | import OpenIDAuthProvider from './OpenIDAuthProvider' 4 | 5 | export interface GoogleConfig extends AuthTypeConfig { 6 | driver: 'google' 7 | clientId: string 8 | clientSecret: string 9 | redirectUri: string 10 | } 11 | 12 | export default class GoogleAuthProvider extends AuthProvider { 13 | 14 | private provider: OpenIDAuthProvider 15 | constructor(config: GoogleConfig) { 16 | super() 17 | this.provider = new OpenIDAuthProvider({ 18 | ...config, 19 | driver: 'openid', 20 | issuerUrl: 'https://accounts.google.com', 21 | responseTypes: ['id_token'], 22 | }) 23 | } 24 | 25 | async start(ctx: AuthContext): Promise { 26 | return await this.provider.start(ctx) 27 | } 28 | 29 | async validate(ctx: AuthContext): Promise { 30 | return await this.provider.validate(ctx) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/platform/src/auth/MultiAuthProvider.ts: -------------------------------------------------------------------------------- 1 | import { getOrganizationByEmail } from '../organizations/OrganizationService' 2 | import { AuthTypeConfig, initProvider } from './Auth' 3 | import AuthProvider, { AuthContext } from './AuthProvider' 4 | 5 | export interface MultiAuthConfig extends AuthTypeConfig { 6 | driver: 'multi' 7 | } 8 | 9 | export default class MultiAuthProvider extends AuthProvider { 10 | async start(ctx: AuthContext): Promise { 11 | 12 | // Redirect to the default for the given org 13 | if (ctx.query.email || ctx.request.body.email) { 14 | const email = ctx.query.email ?? ctx.request.body.email 15 | const organization = await getOrganizationByEmail(email) 16 | if (organization) { 17 | return await initProvider(organization.auth).start(ctx) 18 | } 19 | } 20 | 21 | throw new Error('No organization found.') 22 | } 23 | 24 | async validate(): Promise { 25 | // Will never be called since it routes to other providers 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/platform/src/boot.ts: -------------------------------------------------------------------------------- 1 | import App from './app' 2 | import env from './config/env' 3 | 4 | export default App.init(env()) 5 | .then(app => app.start()) 6 | .catch(error => console.error(error)) 7 | -------------------------------------------------------------------------------- /apps/platform/src/campaigns/CampaignAbortJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import Campaign, { CampaignJobParams } from './Campaign' 3 | import CampaignGenerateListJob from './CampaignGenerateListJob' 4 | import { abortCampaign, getCampaign } from './CampaignService' 5 | 6 | export interface CampaignAbortParams extends CampaignJobParams { 7 | reschedule?: boolean 8 | } 9 | 10 | export default class CampaignAbortJob extends Job { 11 | static $name = 'campaign_abort_job' 12 | 13 | static from({ id, project_id, reschedule }: CampaignAbortParams): CampaignAbortJob { 14 | return new this({ id, project_id, reschedule }).jobId(`cid_${id}_abort`) 15 | } 16 | 17 | static async handler({ id, project_id, reschedule }: CampaignAbortParams) { 18 | const campaign = await getCampaign(id, project_id) 19 | if (!campaign) return 20 | await abortCampaign(campaign) 21 | 22 | const state = reschedule ? 'loading' : 'aborted' 23 | 24 | await Campaign.update(qb => qb.where('id', id), { 25 | state, 26 | }) 27 | 28 | if (state === 'loading' && campaign.type === 'blast') { 29 | await CampaignGenerateListJob.from(campaign).queue() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/platform/src/campaigns/CampaignError.ts: -------------------------------------------------------------------------------- 1 | import { ErrorType } from '../core/errors' 2 | 3 | export default { 4 | CampaignDoesNotExist: { 5 | message: 'The requested campaign does not exist or you do not have access.', 6 | code: 2000, 7 | statusCode: 400, 8 | }, 9 | CampaignFinished: { 10 | message: 'The campaign has already finished and cannot be modified.', 11 | code: 2001, 12 | statusCode: 400, 13 | }, 14 | } as Record 15 | -------------------------------------------------------------------------------- /apps/platform/src/campaigns/CampaignStateJob.ts: -------------------------------------------------------------------------------- 1 | import { subDays } from 'date-fns' 2 | import { Job } from '../queue' 3 | import Campaign from './Campaign' 4 | import { CacheKeys, updateCampaignProgress } from './CampaignService' 5 | import App from '../app' 6 | 7 | export default class CampaignStateJob extends Job { 8 | static $name = 'campaign_state_job' 9 | 10 | static async handler() { 11 | 12 | // Fetch anything that is currently running, has finished 13 | // within the last two days or has activity since last run 14 | const openedCampaignIds = await App.main.redis.smembers(CacheKeys.pendingStats).then(ids => ids.map(parseInt).filter(x => x)) 15 | const campaigns = await Campaign.query() 16 | .whereIn('state', ['loading', 'scheduled', 'running']) 17 | .orWhere(function(qb) { 18 | qb.where('state', 'finished') 19 | .where('send_at', '>', subDays(Date.now(), 2)) 20 | }) 21 | .orWhereIn('id', openedCampaignIds) as Campaign[] 22 | 23 | for (const campaign of campaigns) { 24 | await updateCampaignProgress(campaign) 25 | } 26 | 27 | if (campaigns.length) { 28 | await App.main.redis.srem(CacheKeys.pendingStats, ...campaigns.map(c => c.id)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/platform/src/campaigns/ProcessCampaignsJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import Campaign from './Campaign' 3 | import CampaignGenerateListJob from './CampaignGenerateListJob' 4 | import CampaignEnqueueSendsJob from './CampaignEnqueueSendsJob' 5 | 6 | export default class ProcessCampaignsJob extends Job { 7 | static $name = 'process_campaigns_job' 8 | 9 | static async handler() { 10 | 11 | const campaigns = await Campaign.query() 12 | .whereIn('state', ['loading', 'scheduled', 'running']) 13 | .whereNotNull('send_at') 14 | .whereNull('deleted_at') 15 | .where('type', 'blast') as Campaign[] 16 | for (const campaign of campaigns) { 17 | 18 | // When in loading state we need to regenerate send list 19 | if (campaign.state === 'loading') { 20 | await CampaignGenerateListJob.from(campaign).queue() 21 | } 22 | 23 | // Start looking through messages that are ready to send 24 | await CampaignEnqueueSendsJob.from(campaign).queue() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/platform/src/config/channels.ts: -------------------------------------------------------------------------------- 1 | import EmailChannel from '../providers/email/EmailChannel' 2 | import TextChannel from '../providers/text/TextChannel' 3 | import WebhookChannel from '../providers/webhook/WebhookChannel' 4 | import PushChannel from '../providers/push/PushChannel' 5 | 6 | export type Channel = EmailChannel | TextChannel | PushChannel | WebhookChannel 7 | export type ChannelType = 'email' | 'push' | 'text' | 'webhook' 8 | -------------------------------------------------------------------------------- /apps/platform/src/config/logger.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | import pretty from 'pino-pretty' 3 | import ErrorHandler, { ErrorConfig } from '../error/ErrorHandler' 4 | 5 | export const logger = pino({ 6 | level: process.env.LOG_LEVEL || 'warn', 7 | }, process.env.LOG_PRETTY_PRINT ? pretty({ colorize: true }) : undefined) 8 | 9 | export default async (config: ErrorConfig) => { 10 | return new ErrorHandler(config) 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/src/config/storage.ts: -------------------------------------------------------------------------------- 1 | import Storage, { StorageConfig } from '../storage/Storage' 2 | 3 | export default (config: StorageConfig) => { 4 | return new Storage(config) 5 | } 6 | -------------------------------------------------------------------------------- /apps/platform/src/core/Lock.ts: -------------------------------------------------------------------------------- 1 | import App from '../app' 2 | import { logger } from '../config/logger' 3 | import { uuid } from '../utilities' 4 | 5 | interface LockParams { 6 | key: string 7 | owner?: string 8 | timeout?: number 9 | } 10 | 11 | export const acquireLock = async ({ 12 | key, 13 | owner, 14 | timeout = 60, 15 | }: LockParams) => { 16 | try { 17 | const result = await App.main.redis.set( 18 | `lock:${key}`, 19 | owner ?? uuid(), 20 | 'EX', 21 | timeout, 22 | 'NX', 23 | ) 24 | 25 | // Because of the NX condition, value will only be set 26 | // if it hasn't been set already (original owner) 27 | if (result === null) { 28 | 29 | // Since we know there already is a lock, lets see if 30 | // it is this instance that owns it 31 | if (owner) { 32 | const value = await App.main.redis.get(`lock:${key}`) 33 | return value === owner 34 | } 35 | return false 36 | } 37 | return true 38 | } catch (err) { 39 | logger.warn({ err, key, timeout }, 'lock:error') 40 | return false 41 | } 42 | } 43 | 44 | export const releaseLock = async (key: string) => { 45 | await App.main.redis.del(`lock:${key}`) 46 | } 47 | -------------------------------------------------------------------------------- /apps/platform/src/core/aws.ts: -------------------------------------------------------------------------------- 1 | export interface AWSConfig { 2 | region: string 3 | credentials: AWSCredentials 4 | } 5 | 6 | export interface AWSCredentials { 7 | accessKeyId: string 8 | secretAccessKey: string 9 | } 10 | -------------------------------------------------------------------------------- /apps/platform/src/core/errors.ts: -------------------------------------------------------------------------------- 1 | export interface ErrorType { 2 | message: string 3 | code: number 4 | statusCode?: number 5 | } 6 | 7 | export class InternalError extends Error { 8 | 9 | readonly errorCode?: number 10 | readonly statusCode?: number 11 | constructor(error: ErrorType) 12 | constructor(message: string, statusCode?: number, errorCode?: number) 13 | constructor( 14 | message: string | ErrorType, 15 | statusCode?: number, 16 | errorCode?: number, 17 | ) { 18 | if (typeof message === 'string') { 19 | super(message) 20 | this.statusCode = statusCode 21 | this.errorCode = errorCode 22 | } else { 23 | super(message.message) 24 | this.statusCode = message.statusCode 25 | this.errorCode = message.code 26 | } 27 | } 28 | 29 | toJSON() { 30 | const body: { [key: string]: any } = { 31 | status: 'error', 32 | error: this.message || '', 33 | } 34 | if (this.errorCode) { 35 | body.code = this.errorCode 36 | } 37 | return body 38 | } 39 | } 40 | 41 | export class RequestError extends InternalError { } 42 | -------------------------------------------------------------------------------- /apps/platform/src/error/BugSnagProvider.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandlerTypeConfig } from './ErrorHandler' 2 | import ErrorHandlingProvider from './ErrorHandlerProvider' 3 | import Koa from 'koa' 4 | import Bugsnag from '@bugsnag/js' 5 | import BugsnagPluginKoa from '@bugsnag/plugin-koa' 6 | 7 | export interface BugSnagConfig extends ErrorHandlerTypeConfig { 8 | driver: 'bugsnag' 9 | apiKey: string 10 | } 11 | 12 | export default class BugSnagProvider implements ErrorHandlingProvider { 13 | constructor(config: BugSnagConfig) { 14 | Bugsnag.start({ 15 | apiKey: config.apiKey, 16 | logger: null, 17 | enabledReleaseStages: ['staging', 'production'], 18 | plugins: [BugsnagPluginKoa], 19 | }) 20 | } 21 | 22 | attach(api: Koa) { 23 | const middleware = Bugsnag.getPlugin('koa') 24 | if (middleware) { 25 | api.use(middleware.requestHandler) 26 | api.on('error', (err, ctx) => { 27 | if (ctx.state.admin) ctx.bugsnag.setUser(ctx.state.admin.id.toString()) 28 | middleware.errorHandler(err, ctx) 29 | }) 30 | } 31 | } 32 | 33 | notify(error: Error, context?: Record) { 34 | Bugsnag.notify(error, (event) => { 35 | context && event.addMetadata('context', context) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/platform/src/error/ErrorHandlerProvider.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | 3 | export type ErrorHandlerProviderName = 'bugsnag' | 'sentry' | 'logger' 4 | 5 | export default interface ErrorHandlerProvider { 6 | attach(api: Koa): void 7 | notify(error: Error, context?: Record): void 8 | } 9 | -------------------------------------------------------------------------------- /apps/platform/src/error/LoggerProvider.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandlerTypeConfig } from './ErrorHandler' 2 | import ErrorHandlingProvider from './ErrorHandlerProvider' 3 | import { logger } from '../config/logger' 4 | 5 | export interface LoggerErrorConfig extends ErrorHandlerTypeConfig { 6 | driver: 'logger' 7 | } 8 | 9 | export default class LoggerErrorProvider implements ErrorHandlingProvider { 10 | 11 | attach() { /** */ } 12 | 13 | notify(error: Error, context?: Record) { 14 | logger.error({ 15 | error, 16 | context, 17 | }, error.message) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/platform/src/error/SentryProvider.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node' 2 | import Koa from 'koa' 3 | import { ErrorHandlerTypeConfig } from './ErrorHandler' 4 | import ErrorHandlingProvider from './ErrorHandlerProvider' 5 | 6 | export interface SentryConfig extends ErrorHandlerTypeConfig { 7 | driver: 'sentry' 8 | dsn: string 9 | } 10 | 11 | export default class SentryProvider implements ErrorHandlingProvider { 12 | constructor(config: SentryConfig) { 13 | Sentry.init({ dsn: config.dsn }) 14 | } 15 | 16 | attach(api: Koa) { 17 | api.on('error', (err, ctx) => { 18 | Sentry.withScope((scope) => { 19 | if (ctx.state.admin) scope.setUser({ id: ctx.state.admin.id.toString() }) 20 | scope.addEventProcessor((event) => { 21 | return Sentry.addRequestDataToEvent(event, ctx.request) 22 | }) 23 | Sentry.captureException(err) 24 | }) 25 | }) 26 | } 27 | 28 | notify(error: Error, context?: Record) { 29 | Sentry.captureException(error, { 30 | extra: context, 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/platform/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Api } from './api' 2 | export { default as App } from './app' 3 | export { default as env } from './config/env' 4 | export { default as Logger } from './config/logger' 5 | export { default as Model } from './core/Model' 6 | export { default as Worker } from './worker' 7 | export { default as Job } from './queue/Job' 8 | export { default as Admin } from './auth/Admin' 9 | export { default as Campaign } from './campaigns/Campaign' 10 | export { default as Organization } from './organizations/Organization' 11 | export * as jobs from './jobs' 12 | export * as OrganizationService from './organizations/OrganizationService' 13 | export * as Utilities from './utilities' 14 | -------------------------------------------------------------------------------- /apps/platform/src/jobs.ts: -------------------------------------------------------------------------------- 1 | export { default as EventPostJob } from './client/EventPostJob' 2 | export { default as UserAliasJob } from './users/UserAliasJob' 3 | export { default as UserPatchJob } from './users/UserPatchJob' 4 | -------------------------------------------------------------------------------- /apps/platform/src/journey/Journey.ts: -------------------------------------------------------------------------------- 1 | import Model, { ModelParams } from '../core/Model' 2 | import { User } from '../users/User' 3 | import { setJourneyStepMap } from './JourneyRepository' 4 | import { JourneyStepMapParams } from './JourneyStep' 5 | 6 | export default class Journey extends Model { 7 | name!: string 8 | project_id!: number 9 | description?: string 10 | published!: boolean 11 | deleted_at?: Date 12 | tags?: string[] 13 | stats?: Record 14 | stats_at?: Date 15 | 16 | static jsonAttributes = ['stats'] 17 | 18 | static async create(project_id: number, name: string, stepMap: JourneyStepMapParams) { 19 | const journey = await this.insertAndFetch({ 20 | project_id, 21 | name, 22 | published: true, 23 | }) 24 | const { steps, children } = await setJourneyStepMap(journey, stepMap) 25 | return { journey, steps, children } 26 | } 27 | } 28 | 29 | export type JourneyParams = Omit 30 | export type UpdateJourneyParams = Omit 31 | 32 | export interface JourneyEntranceTriggerParams { 33 | entrance_id: number 34 | user: Pick & { external_id: string, device_token?: string } 35 | event?: Record 36 | } 37 | -------------------------------------------------------------------------------- /apps/platform/src/journey/JourneyError.ts: -------------------------------------------------------------------------------- 1 | import { ErrorType } from '../core/errors' 2 | 3 | export default { 4 | JourneyDoesNotExist: { 5 | message: 'The requested journey does not exist or you do not have access.', 6 | code: 9000, 7 | statusCode: 400, 8 | }, 9 | JourneyStepDoesNotExist: { 10 | message: 'The request journey step is invalid', 11 | code: 9001, 12 | statusCode: 400, 13 | }, 14 | } as Record 15 | -------------------------------------------------------------------------------- /apps/platform/src/journey/JourneyProcessJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import Journey from './Journey' 3 | import { JourneyState } from './JourneyState' 4 | import { JourneyUserStep } from './JourneyStep' 5 | 6 | interface JourneyProcessParams { 7 | entrance_id: number 8 | } 9 | 10 | export default class JourneyProcessJob extends Job { 11 | static $name = 'journey_process_job' 12 | 13 | static from(params: JourneyProcessParams): JourneyProcessJob { 14 | return new this(params) 15 | } 16 | 17 | static async handler({ entrance_id }: JourneyProcessParams) { 18 | 19 | const entrance = await JourneyUserStep.find(entrance_id) 20 | 21 | // invalid entrance id 22 | if (!entrance) { 23 | return 24 | } 25 | 26 | // make sure journey is still active 27 | if (!await Journey.exists(qb => qb.where('id', entrance.journey_id).where('published', true))) { 28 | return 29 | } 30 | 31 | await JourneyState.resume(entrance) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/platform/src/journey/UpdateJourneysJob.ts: -------------------------------------------------------------------------------- 1 | import { chunk } from '../utilities' 2 | import { Job } from '../queue' 3 | import Journey from './Journey' 4 | import App from '../app' 5 | import JourneyStatsJob from './JourneyStatsJob' 6 | 7 | export default class UpdateJourneysJob extends Job { 8 | static $name = 'update_journeys_job' 9 | 10 | static async handler() { 11 | 12 | const { db, queue } = App.main 13 | 14 | await chunk(Journey.query(db), queue.batchSize, async journeys => { 15 | queue.enqueueBatch(journeys.map(({ id }) => JourneyStatsJob.from(id))) 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/platform/src/lists/List.ts: -------------------------------------------------------------------------------- 1 | import Model from '../core/Model' 2 | import { RuleTree } from '../rules/Rule' 3 | 4 | export type ListState = 'draft' | 'ready' | 'loading' 5 | type ListType = 'static' | 'dynamic' 6 | 7 | export default class List extends Model { 8 | project_id!: number 9 | name!: string 10 | type!: ListType 11 | state!: ListState 12 | rule_id?: number 13 | rule?: RuleTree 14 | version!: number 15 | users_count?: number 16 | tags?: string[] 17 | is_visible!: boolean 18 | refreshed_at?: Date | null 19 | deleted_at?: Date 20 | progress?: ListProgress 21 | } 22 | 23 | export type ListProgress = { 24 | complete: number 25 | total: number 26 | } 27 | 28 | export type DynamicList = List & { rule_id: number, rule: RuleTree } 29 | 30 | export class UserList extends Model { 31 | user_id!: number 32 | list_id!: number 33 | event_id!: number 34 | version!: number 35 | deleted_at?: Date 36 | 37 | static tableName = 'user_list' 38 | } 39 | 40 | export type ListUpdateParams = Pick & { rule?: RuleTree, published?: boolean } 41 | export type ListCreateParams = ListUpdateParams & Pick & { rule?: RuleTree } 42 | export type ListVersion = Pick 43 | -------------------------------------------------------------------------------- /apps/platform/src/lists/ListPopulateJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import { DynamicList } from './List' 3 | import { getList, populateList } from './ListService' 4 | 5 | interface ListPopulateParams { 6 | listId: number 7 | projectId: number 8 | } 9 | 10 | export default class ListPopulateJob extends Job { 11 | static $name = 'list_populate_job' 12 | 13 | static from(listId: number, projectId: number): ListPopulateJob { 14 | return new this({ listId, projectId }) 15 | } 16 | 17 | static async handler({ listId, projectId }: ListPopulateParams) { 18 | 19 | const list = await getList(listId, projectId) as DynamicList 20 | if (!list) return 21 | 22 | await populateList(list) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/platform/src/lists/ListRefreshJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import { getDateRuleTypes } from '../rules/RuleService' 3 | import { getList, refreshList } from './ListService' 4 | 5 | interface ListRefreshParams { 6 | listId: number 7 | projectId: number 8 | } 9 | 10 | export default class ListRefreshJob extends Job { 11 | static $name = 'list_refresh_job' 12 | 13 | static from( 14 | listId: number, 15 | projectId: number, 16 | ): ListRefreshJob { 17 | return new this({ listId, projectId }) 18 | } 19 | 20 | static async handler({ listId, projectId }: ListRefreshParams) { 21 | 22 | const list = await getList(listId, projectId) 23 | if (!list || !list.rule_id) return 24 | 25 | const dateRuleTypes = await getDateRuleTypes(list.rule_id) 26 | if (!dateRuleTypes?.dynamic) return 27 | 28 | await refreshList(list, dateRuleTypes) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/platform/src/lists/ProcessListsJob.ts: -------------------------------------------------------------------------------- 1 | import { differenceInHours } from 'date-fns' 2 | import { Job } from '../queue' 3 | import List from './List' 4 | import ListStatsJob from './ListStatsJob' 5 | import ListRefreshJob from './ListRefreshJob' 6 | 7 | export default class ProcessListsJob extends Job { 8 | static $name = 'process_lists_job' 9 | 10 | static async handler() { 11 | 12 | const lists = await List.all(qb => qb.whereNot('state', 'loading')) 13 | for (const list of lists) { 14 | 15 | // Update stats on all lists 16 | await ListStatsJob.from(list.id, list.project_id).queue() 17 | 18 | // Refresh lists with date rules if 24hrs has elapsed 19 | if (list.refreshed_at 20 | && differenceInHours( 21 | new Date(), 22 | list.refreshed_at, 23 | ) >= 24 24 | ) { 25 | await ListRefreshJob.from(list.id, list.project_id).queue() 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/platform/src/lists/UserListMatchJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import { matchingRulesForUser } from '../rules/RuleService' 3 | import { getUser } from '../users/UserRepository' 4 | import { updateUsersLists } from './ListService' 5 | 6 | interface UserListMatchParams { 7 | userId: number 8 | projectId: number 9 | } 10 | 11 | export default class UserListMatchJob extends Job { 12 | static $name = 'user_list_match_job' 13 | 14 | static from(userId: number, projectId: number): UserListMatchJob { 15 | return new this({ userId, projectId }) 16 | } 17 | 18 | static async handler({ userId, projectId }: UserListMatchParams) { 19 | 20 | const user = await getUser(userId, projectId) 21 | if (!user) return 22 | 23 | const results = await matchingRulesForUser(user) 24 | await updateUsersLists(user, results) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/platform/src/organizations/Organization.ts: -------------------------------------------------------------------------------- 1 | import { AuthProviderConfig } from '../auth/Auth' 2 | import Model, { ModelParams } from '../core/Model' 3 | 4 | export interface TrackingOptions { 5 | linkWrap: boolean, 6 | deeplinkMirrorUrl: string | undefined, 7 | } 8 | 9 | export const organizationRoles = [ 10 | 'member', 11 | 'admin', 12 | 'owner', 13 | ] as const 14 | 15 | export type OrganizationRole = (typeof organizationRoles)[number] 16 | 17 | export default class Organization extends Model { 18 | username!: string 19 | domain?: string 20 | auth!: AuthProviderConfig 21 | notification_provider_id?: number 22 | tracking_deeplink_mirror_url?: string 23 | 24 | static jsonAttributes = ['auth'] 25 | } 26 | 27 | export type OrganizationParams = Omit 28 | -------------------------------------------------------------------------------- /apps/platform/src/organizations/OrganizationMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { getDefaultOrganization, getOrganization, getOrganizationByUsername } from './OrganizationService' 3 | import App from '../app' 4 | 5 | export const organizationMiddleware = async (ctx: Context, next: () => void) => { 6 | const organizationId = ctx.cookies.get('organization', { signed: true }) 7 | if (!App.main.env.config.multiOrg) { 8 | ctx.state.organization = await getDefaultOrganization() 9 | } else if (organizationId) { 10 | ctx.state.organization = await getOrganization(parseInt(organizationId)) 11 | } else if (ctx.subdomains && ctx.subdomains[0]) { 12 | const subdomain = ctx.subdomains[0] 13 | ctx.state.organization = await getOrganizationByUsername(subdomain) 14 | } 15 | return next() 16 | } 17 | -------------------------------------------------------------------------------- /apps/platform/src/profile/ProfileController.ts: -------------------------------------------------------------------------------- 1 | import Router from '@koa/router' 2 | import Admin from '../auth/Admin' 3 | import { AuthState } from '../auth/AuthMiddleware' 4 | 5 | const router = new Router({ 6 | prefix: '/profile', 7 | }) 8 | 9 | router.get('/', async ctx => { 10 | ctx.body = await Admin.find(ctx.state.admin!.id) 11 | }) 12 | 13 | export default router 14 | -------------------------------------------------------------------------------- /apps/platform/src/projects/Locale.ts: -------------------------------------------------------------------------------- 1 | import Model, { ModelParams } from '../core/Model' 2 | 3 | export default class Locale extends Model { 4 | project_id!: number 5 | key!: string 6 | label!: string 7 | } 8 | 9 | export type LocaleParams = Omit 10 | -------------------------------------------------------------------------------- /apps/platform/src/projects/Project.ts: -------------------------------------------------------------------------------- 1 | import Model, { ModelParams } from '../core/Model' 2 | 3 | export default class Project extends Model { 4 | organization_id!: number 5 | name!: string 6 | description?: string 7 | deleted_at?: Date 8 | locale!: string 9 | timezone!: string 10 | text_opt_out_message?: string 11 | text_help_message?: string 12 | link_wrap_email?: boolean 13 | link_wrap_push?: boolean 14 | } 15 | 16 | export type ProjectParams = Omit 17 | 18 | export const projectRoles = [ 19 | 'support', 20 | 'editor', 21 | 'admin', 22 | ] as const 23 | 24 | export type ProjectRole = (typeof projectRoles)[number] 25 | -------------------------------------------------------------------------------- /apps/platform/src/projects/ProjectAdmins.ts: -------------------------------------------------------------------------------- 1 | import Model from '../core/Model' 2 | import { ProjectRole } from './Project' 3 | 4 | export class ProjectAdmin extends Model { 5 | project_id!: number 6 | admin_id?: number 7 | role!: ProjectRole 8 | deleted_at?: Date 9 | } 10 | 11 | export type ProjectAdminParams = Pick 12 | -------------------------------------------------------------------------------- /apps/platform/src/projects/ProjectApiKey.ts: -------------------------------------------------------------------------------- 1 | import Model from '../core/Model' 2 | import { ProjectRole } from './Project' 3 | 4 | export class ProjectApiKey extends Model { 5 | project_id!: number 6 | value!: string 7 | name!: string 8 | scope!: 'public' | 'secret' 9 | role!: ProjectRole 10 | description?: string 11 | deleted_at?: Date 12 | } 13 | 14 | export type ProjectApiKeyParams = Pick 15 | -------------------------------------------------------------------------------- /apps/platform/src/projects/ProjectError.ts: -------------------------------------------------------------------------------- 1 | import { ErrorType } from '../core/errors' 2 | 3 | export const ProjectError = { 4 | ProjectDoesNotExist: { 5 | message: 'The requested project does not exist.', 6 | code: 6000, 7 | statusCode: 404, 8 | }, 9 | ProjectAccessDenied: { 10 | message: 'You do not have permission to access this project.', 11 | code: 6001, 12 | statusCode: 403, 13 | }, 14 | } satisfies Record 15 | -------------------------------------------------------------------------------- /apps/platform/src/projects/__tests__/ProjectTestHelpers.ts: -------------------------------------------------------------------------------- 1 | import Admin from '../../auth/Admin' 2 | import { uuid } from '../../utilities' 3 | import { createProject } from '../ProjectService' 4 | 5 | export const createTestProject = async () => { 6 | const admin = await Admin.insertAndFetch({ 7 | first_name: uuid(), 8 | last_name: uuid(), 9 | email: `${uuid()}@test.com`, 10 | }) 11 | return await createProject(admin, { 12 | name: uuid(), 13 | timezone: 'utc', 14 | locale: 'en', 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /apps/platform/src/providers/LoggerProvider.ts: -------------------------------------------------------------------------------- 1 | import Provider from '../providers/Provider' 2 | import type { DriverConfig } from '../config/env' 3 | 4 | export type LoggerProviderName = 'logger' 5 | export class LoggerProvider extends Provider { } 6 | export interface LoggerConfig extends DriverConfig { 7 | driver: LoggerProviderName 8 | } 9 | -------------------------------------------------------------------------------- /apps/platform/src/providers/MessageTrigger.ts: -------------------------------------------------------------------------------- 1 | export interface MessageTrigger { 2 | campaign_id: number 3 | user_id: number 4 | event_id?: number 5 | reference_type?: string 6 | reference_id?: string 7 | } 8 | -------------------------------------------------------------------------------- /apps/platform/src/providers/analytics/Analytics.ts: -------------------------------------------------------------------------------- 1 | import { DriverConfig } from '../../config/env' 2 | import { AnalyticsProvider, AnalyticsProviderName, AnalyticsUserEvent } from './AnalyticsProvider' 3 | 4 | export interface AnalyticsTypeConfig extends DriverConfig { 5 | driver: AnalyticsProviderName 6 | } 7 | 8 | export default class Analytics { 9 | readonly provider?: AnalyticsProvider 10 | constructor(provider?: AnalyticsProvider) { 11 | this.provider = provider 12 | } 13 | 14 | async track(event: AnalyticsUserEvent) { 15 | await this.provider?.track(event) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/platform/src/providers/analytics/AnalyticsProvider.ts: -------------------------------------------------------------------------------- 1 | import { camelcase, titleize } from '../../render/Helpers/String' 2 | import { UserEventParams } from '../../users/UserEvent' 3 | import { snakeCase } from '../../utilities' 4 | import Provider, { ProviderGroup } from '../Provider' 5 | 6 | export type AnalyticsProviderName = 'segment' | 'mixpanel' | 'posthog' 7 | 8 | export type AnalyticsUserEvent = UserEventParams & { 9 | external_id: string 10 | anonymous_id?: string 11 | } 12 | 13 | export type Convention = 'snake_case' | 'camel_case' | 'title_case' 14 | 15 | export abstract class AnalyticsProvider extends Provider { 16 | abstract track(event: AnalyticsUserEvent): Promise 17 | 18 | static group = 'analytics' as ProviderGroup 19 | 20 | tranformEventName(event: string, convention: Convention) { 21 | switch (convention) { 22 | case 'camel_case': return camelcase(event) 23 | case 'snake_case': return snakeCase(event) 24 | case 'title_case': return titleize(event) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/platform/src/providers/analytics/index.ts: -------------------------------------------------------------------------------- 1 | import { loadDefaultProvider } from '../ProviderRepository' 2 | import Analytics from './Analytics' 3 | import { AnalyticsProvider, AnalyticsProviderName } from './AnalyticsProvider' 4 | import MixpanelAnalyticsProvider from './MixpanelProvider' 5 | import PostHogAnalyticsProvider from './PosthogProvider' 6 | import SegmentAnalyticsProvider from './SegmentProvider' 7 | 8 | type AnalyticsProviderDerived = { new (): AnalyticsProvider } & typeof AnalyticsProvider 9 | export const typeMap: Record = { 10 | segment: SegmentAnalyticsProvider, 11 | posthog: PostHogAnalyticsProvider, 12 | mixpanel: MixpanelAnalyticsProvider, 13 | } 14 | 15 | export const providerMap = (record: { type: AnalyticsProviderName }): AnalyticsProvider => { 16 | return typeMap[record.type].fromJson(record) 17 | } 18 | 19 | export const loadAnalytics = async (projectId: number): Promise => { 20 | const provider = await loadDefaultProvider('analytics', projectId, providerMap) 21 | return new Analytics(provider) 22 | } 23 | 24 | export const analyticsProviders = Object.values(typeMap) 25 | -------------------------------------------------------------------------------- /apps/platform/src/providers/email/Email.ts: -------------------------------------------------------------------------------- 1 | export type NamedEmail = { name: string, address: string } 2 | 3 | export interface Email { 4 | to: string 5 | from: string | NamedEmail 6 | cc?: string 7 | bcc?: string 8 | reply_to?: string 9 | subject: string 10 | text: string 11 | html: string 12 | headers?: Record 13 | list?: { unsubscribe: string } 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/src/providers/email/EmailProvider.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer' 2 | import { LoggerProviderName } from '../LoggerProvider' 3 | import Provider, { ProviderGroup } from '../Provider' 4 | import { Email } from './Email' 5 | 6 | export type EmailProviderName = 'ses' | 'smtp' | 'mailgun' | 'sendgrid' | LoggerProviderName 7 | 8 | export default abstract class EmailProvider extends Provider { 9 | 10 | unsubscribe?: string 11 | transport?: nodemailer.Transporter 12 | boot?(): void 13 | 14 | static group = 'email' as ProviderGroup 15 | 16 | async send(message: Email): Promise { 17 | return await this.transport?.sendMail(message) 18 | } 19 | 20 | async verify(): Promise { 21 | await this.transport?.verify() 22 | return true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/platform/src/providers/email/index.ts: -------------------------------------------------------------------------------- 1 | import { loadProvider } from '../ProviderRepository' 2 | import EmailChannel from './EmailChannel' 3 | import EmailProvider, { EmailProviderName } from './EmailProvider' 4 | import LoggerEmailProvider from './LoggerEmailProvider' 5 | import MailgunEmailProvider from './MailgunEmailProvider' 6 | import SESEmailProvider from './SESEmailProvider' 7 | import SMTPEmailProvider from './SMPTEmailProvider' 8 | import SendGridEmailProvider from './SendGridEmailProvider' 9 | 10 | type EmailProviderDerived = { new (): EmailProvider } & typeof EmailProvider 11 | export const typeMap: Record = { 12 | mailgun: MailgunEmailProvider, 13 | sendgrid: SendGridEmailProvider, 14 | ses: SESEmailProvider, 15 | smtp: SMTPEmailProvider, 16 | logger: LoggerEmailProvider, 17 | } 18 | 19 | export const providerMap = (record: { type: EmailProviderName }): EmailProvider => { 20 | return typeMap[record.type].fromJson(record) 21 | } 22 | 23 | export const loadEmailChannel = async (providerId: number, projectId: number): Promise => { 24 | const provider = await loadProvider(providerId, providerMap, projectId) 25 | if (!provider) return 26 | return new EmailChannel(provider) 27 | } 28 | 29 | export const emailProviders = Object.values(typeMap) 30 | -------------------------------------------------------------------------------- /apps/platform/src/providers/push/LoggerPushProvider.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../../config/logger' 2 | import { randomInt, sleep } from '../../utilities' 3 | import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider' 4 | import { createController } from '../ProviderService' 5 | import { Push, PushResponse } from './Push' 6 | import { PushProvider } from './PushProvider' 7 | export default class LoggerPushProvider extends PushProvider { 8 | addLatency?: boolean 9 | 10 | static namespace = 'logger' 11 | static meta = { 12 | name: 'Logger', 13 | icon: 'https://parcelvoy.com/providers/logger.svg', 14 | } 15 | 16 | static schema = ProviderSchema('loggerPushProviderParams', { 17 | type: 'object', 18 | }) 19 | 20 | async send(push: Push): Promise { 21 | 22 | // Allow for having random latency to aid in performance testing 23 | if (this.addLatency) await sleep(randomInt()) 24 | 25 | logger.info(push, 'provider:push:logger') 26 | return { 27 | push, 28 | success: true, 29 | response: '', 30 | invalidTokens: [], 31 | } 32 | } 33 | 34 | static controllers(): ProviderControllers { 35 | return { admin: createController('push', this) } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/platform/src/providers/push/Push.ts: -------------------------------------------------------------------------------- 1 | export interface Push { 2 | tokens: string | string[] 3 | topic: string 4 | title: string 5 | body: string 6 | custom: Record 7 | } 8 | 9 | export interface PushResponse { 10 | push: Push 11 | success: boolean 12 | response?: string 13 | invalidTokens: string[] 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/src/providers/push/PushChannel.ts: -------------------------------------------------------------------------------- 1 | import { PushTemplate } from '../../render/Template' 2 | import { Variables } from '../../render' 3 | import { PushProvider } from './PushProvider' 4 | import { PushResponse } from './Push' 5 | 6 | export default class PushChannel { 7 | readonly provider: PushProvider 8 | constructor(provider?: PushProvider) { 9 | if (provider) { 10 | this.provider = provider 11 | this.provider.boot?.() 12 | } else { 13 | throw new Error('A valid push notification provider must be defined!') 14 | } 15 | } 16 | 17 | async send(template: PushTemplate, variables: Variables): Promise { 18 | 19 | // Find tokens from active devices with push enabled 20 | const tokens = variables.user.pushEnabledDevices.map(device => device.token) 21 | 22 | // If no tokens, don't send 23 | if (tokens?.length <= 0) return 24 | 25 | const push = { 26 | tokens, 27 | ...template.compile(variables), 28 | } 29 | 30 | return await this.provider.send(push) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/platform/src/providers/push/PushError.ts: -------------------------------------------------------------------------------- 1 | export default class PushError extends Error { 2 | invalidTokens: string[] 3 | constructor(provider: string, message: string | undefined, invalidTokens: string[]) { 4 | super(`Push Error: ${provider}: ${message}`) 5 | this.invalidTokens = invalidTokens 6 | Error.captureStackTrace(this, PushError) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/platform/src/providers/push/PushProvider.ts: -------------------------------------------------------------------------------- 1 | import Provider, { ProviderGroup } from '../Provider' 2 | import { Push, PushResponse } from './Push' 3 | 4 | export type PushProviderName = 'local' | 'logger' 5 | 6 | export abstract class PushProvider extends Provider { 7 | boot?(): void 8 | abstract send(message: Push): Promise 9 | 10 | static group = 'push' as ProviderGroup 11 | } 12 | -------------------------------------------------------------------------------- /apps/platform/src/providers/push/index.ts: -------------------------------------------------------------------------------- 1 | import { loadProvider } from '../ProviderRepository' 2 | import LocalPushProvider from './LocalPushProvider' 3 | import LoggerPushProvider from './LoggerPushProvider' 4 | import PushChannel from './PushChannel' 5 | import { PushProvider, PushProviderName } from './PushProvider' 6 | 7 | type PushProviderDerived = { new (): PushProvider } & typeof PushProvider 8 | export const typeMap: Record = { 9 | local: LocalPushProvider, 10 | logger: LoggerPushProvider, 11 | } 12 | 13 | export const providerMap = (record: { type: PushProviderName }): PushProvider => { 14 | return typeMap[record.type].fromJson(record) 15 | } 16 | 17 | export const loadPushChannel = async (providerId: number, projectId: number): Promise => { 18 | const provider = await loadProvider(providerId, providerMap, projectId) 19 | if (!provider) return 20 | return new PushChannel(provider) 21 | } 22 | 23 | export const pushProviders = Object.values(typeMap) 24 | -------------------------------------------------------------------------------- /apps/platform/src/providers/text/TextError.ts: -------------------------------------------------------------------------------- 1 | import { ContextError } from '../../error/ErrorHandler' 2 | 3 | export default class TextError extends ContextError { 4 | phone: string 5 | constructor(type: string, phone: string, message: string, context: any = {}) { 6 | super(`Text Error: ${message}`) 7 | this.phone = phone 8 | this.context = { phone, type, ...context } 9 | Error.captureStackTrace(this, TextError) 10 | } 11 | } 12 | 13 | export class UnsubscribeTextError extends TextError { } 14 | 15 | export class UndeliverableTextError extends TextError { } 16 | 17 | export class RateLimitTextError extends TextError { } 18 | -------------------------------------------------------------------------------- /apps/platform/src/providers/text/TextMessage.ts: -------------------------------------------------------------------------------- 1 | export interface TextMessage { 2 | to: string 3 | text: string 4 | } 5 | 6 | export interface InboundTextMessage extends TextMessage { 7 | from: string 8 | } 9 | 10 | export interface TextResponse { 11 | message: TextMessage 12 | success: boolean 13 | response: string 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/src/providers/webhook/LoggerWebhookProvider.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../../config/logger' 2 | import { randomInt, sleep } from '../../utilities' 3 | import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider' 4 | import { createController } from '../ProviderService' 5 | import { Webhook, WebhookResponse } from './Webhook' 6 | import { WebhookProvider } from './WebhookProvider' 7 | 8 | export default class LoggerWebhookProvider extends WebhookProvider { 9 | addLatency?: boolean 10 | 11 | static namespace = 'logger' 12 | static meta = { 13 | name: 'Logger', 14 | icon: 'https://parcelvoy.com/providers/logger.svg', 15 | } 16 | 17 | static schema = ProviderSchema('loggerWebhookProviderParams', { 18 | type: 'object', 19 | }) 20 | 21 | async send(options: Webhook): Promise { 22 | 23 | // Allow for having random latency to aid in performance testing 24 | if (this.addLatency) await sleep(randomInt()) 25 | 26 | logger.info(options, 'provider:webhook:logger') 27 | return { 28 | message: options, 29 | success: true, 30 | response: '', 31 | } 32 | } 33 | 34 | static controllers(): ProviderControllers { 35 | return { admin: createController('webhook', this) } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/platform/src/providers/webhook/Webhook.ts: -------------------------------------------------------------------------------- 1 | export interface Webhook { 2 | method: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' 3 | endpoint: string 4 | headers: Record 5 | body?: Record 6 | } 7 | 8 | export interface WebhookResponse { 9 | message: Webhook 10 | success: boolean 11 | response: Record | string 12 | } 13 | -------------------------------------------------------------------------------- /apps/platform/src/providers/webhook/WebhookProvider.ts: -------------------------------------------------------------------------------- 1 | import Provider, { ProviderGroup } from '../Provider' 2 | import { Webhook, WebhookResponse } from './Webhook' 3 | 4 | export type WebhookProviderName = 'local' | 'logger' 5 | 6 | export abstract class WebhookProvider extends Provider { 7 | abstract send(message: Webhook): Promise 8 | 9 | static group = 'webhook' as ProviderGroup 10 | } 11 | -------------------------------------------------------------------------------- /apps/platform/src/providers/webhook/index.ts: -------------------------------------------------------------------------------- 1 | import { loadProvider } from '../ProviderRepository' 2 | import LocalWebhookProvider from './LocalWebhookProvider' 3 | import LoggerWebhookProvider from './LoggerWebhookProvider' 4 | import WebhookChannel from './WebhookChannel' 5 | import { WebhookProvider, WebhookProviderName } from './WebhookProvider' 6 | 7 | type WebhookProviderDerived = { new (): WebhookProvider } & typeof WebhookProvider 8 | export const typeMap: Record = { 9 | local: LocalWebhookProvider, 10 | logger: LoggerWebhookProvider, 11 | } 12 | 13 | export const providerMap = (record: { type: WebhookProviderName }): WebhookProvider => { 14 | return typeMap[record.type].fromJson(record) 15 | } 16 | 17 | export const loadWebhookChannel = async (providerId: number, projectId: number): Promise => { 18 | const provider = await loadProvider(providerId, providerMap, projectId) 19 | if (!provider) return 20 | return new WebhookChannel(provider) 21 | } 22 | 23 | export const webhookProviders = Object.values(typeMap) 24 | -------------------------------------------------------------------------------- /apps/platform/src/queue/QueueProvider.ts: -------------------------------------------------------------------------------- 1 | import Queue from './Queue' 2 | import { EncodedJob } from './Job' 3 | 4 | export type QueueProviderName = 'sqs' | 'redis' | 'memory' | 'logger' 5 | 6 | export interface Metric { 7 | date: Date 8 | count: number 9 | } 10 | 11 | export interface QueueMetric { 12 | data: Metric[] 13 | waiting: number 14 | } 15 | 16 | export enum MetricPeriod { 17 | FIFTEEN_MINUTES = 15, 18 | ONE_HOUR = 60, 19 | FOUR_HOURS = 240, 20 | ONE_WEEK = 10080, 21 | TWO_WEEKS = 20160, 22 | } 23 | 24 | export default interface QueueProvider { 25 | queue: Queue 26 | batchSize: number 27 | enqueue(job: EncodedJob): Promise 28 | enqueueBatch(jobs: EncodedJob[]): Promise 29 | delay(job: EncodedJob, milliseconds: number): Promise 30 | start(): void 31 | close(): void 32 | metrics?(period: MetricPeriod): Promise 33 | failed?(): Promise 34 | } 35 | -------------------------------------------------------------------------------- /apps/platform/src/queue/index.ts: -------------------------------------------------------------------------------- 1 | import Job, { EncodedJob } from './Job' 2 | import Queue from './Queue' 3 | 4 | export { Job, EncodedJob } 5 | export default Queue 6 | -------------------------------------------------------------------------------- /apps/platform/src/render/Helpers/Url.ts: -------------------------------------------------------------------------------- 1 | import { checkString } from './String' 2 | 3 | /** 4 | * Encodes a Uniform Resource Identifier (URI) component 5 | * by replacing each instance of certain characters by 6 | * one, two, three, or four escape sequences representing 7 | * the UTF-8 encoding of the character. 8 | */ 9 | export const encodeURI = function(str: string): string { 10 | checkString(str) 11 | return encodeURIComponent(str) 12 | } 13 | 14 | /** 15 | * Escape the given string by replacing characters with escape sequences. 16 | * Useful for allowing the string to be used in a URL, etc. 17 | */ 18 | export const escape = function(str: string): URLSearchParams { 19 | checkString(str) 20 | return new URLSearchParams(str) 21 | } 22 | 23 | /** 24 | * Decode a Uniform Resource Identifier (URI) component. 25 | */ 26 | export const decodeURI = function(str: string): string { 27 | checkString(str) 28 | return decodeURIComponent(str) 29 | } 30 | -------------------------------------------------------------------------------- /apps/platform/src/render/Helpers/Util.ts: -------------------------------------------------------------------------------- 1 | export const isType = (value: any, type: string): boolean => { 2 | if (!value) return false 3 | if (type === 'array') return Array.isArray(value) 4 | if (type === 'object') return typeof value === 'object' && value !== null 5 | return typeof value === type 6 | } 7 | 8 | export const checkType = (value: any, type: string, message?: string): boolean => { 9 | if (!isType(value, type)) { 10 | throw new TypeError(message ?? `Expected a ${type}.`) 11 | } 12 | return true 13 | } 14 | -------------------------------------------------------------------------------- /apps/platform/src/render/Resource.ts: -------------------------------------------------------------------------------- 1 | import Model, { ModelParams } from '../core/Model' 2 | 3 | export type ResourceType = 'font' | 'snippet' 4 | 5 | export default class Resource extends Model { 6 | project_id!: number 7 | type!: ResourceType 8 | name!: string 9 | value!: Record 10 | 11 | static jsonAttributes = ['value'] 12 | } 13 | 14 | export type ResourceParams = Omit 15 | -------------------------------------------------------------------------------- /apps/platform/src/render/ResourceService.ts: -------------------------------------------------------------------------------- 1 | import Resource, { ResourceParams, ResourceType } from './Resource' 2 | 3 | export const allResources = async (projectId: number, type?: ResourceType): Promise => { 4 | return await Resource.all(qb => { 5 | if (type) { 6 | qb.where('type', type) 7 | } 8 | return qb.where('project_id', projectId) 9 | }) 10 | } 11 | 12 | export const getResource = async (id: number, projectId: number) => { 13 | return await Resource.find(id, qb => qb.where('project_id', projectId)) 14 | } 15 | 16 | export const createResource = async (projectId: number, params: ResourceParams) => { 17 | return await Resource.insertAndFetch({ 18 | ...params, 19 | project_id: projectId, 20 | }) 21 | } 22 | 23 | export const deleteResource = async (id: number, projectId: number) => { 24 | return await Resource.deleteById(id, qb => qb.where('project_id', projectId)) 25 | } 26 | -------------------------------------------------------------------------------- /apps/platform/src/render/__tests__/__snapshots__/LinkService.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LinkService clickWrapHtml links are wrapped 1`] = `"This is some html Test Link"`; 4 | 5 | exports[`LinkService openWrapHtml open tracking image is added to end of body 1`] = `"This is some html\\"\\""`; 6 | 7 | exports[`LinkService preheaderWrapHtml complex html injects preheader 1`] = ` 8 | " 9 | This is some preheader 12 | This is some html 13 | 14 | " 15 | `; 16 | 17 | exports[`LinkService preheaderWrapHtml simple html injects preheader 1`] = `"This is some preheaderThis is some html"`; 18 | -------------------------------------------------------------------------------- /apps/platform/src/rules/ArrayRule.ts: -------------------------------------------------------------------------------- 1 | import { RuleCheck, RuleEvalException } from './RuleEngine' 2 | import { queryValue } from './RuleHelpers' 3 | 4 | export default { 5 | check({ rule, value }) { 6 | const values = queryValue(value, rule, item => item) 7 | 8 | if (rule.operator === 'is set') { 9 | return values.some(x => Array.isArray(x)) 10 | } 11 | 12 | if (rule.operator === 'is not set') { 13 | return values.every(x => !Array.isArray(x)) 14 | } 15 | 16 | if (rule.operator === 'empty') { 17 | return values.every(x => !Array.isArray(x) || x.length === 0) 18 | } 19 | 20 | throw new RuleEvalException(rule, 'unknown operator: ' + rule.operator) 21 | }, 22 | } satisfies RuleCheck 23 | -------------------------------------------------------------------------------- /apps/platform/src/rules/BooleanRule.ts: -------------------------------------------------------------------------------- 1 | import { RuleCheck } from './RuleEngine' 2 | import { queryValue } from './RuleHelpers' 3 | 4 | export default { 5 | check({ rule, value }) { 6 | const values = queryValue(value, rule, item => { 7 | if (typeof item === 'boolean') return item 8 | if (typeof item === 'string') return item === 'true' 9 | if (typeof item === 'number') return item === 1 10 | return false 11 | }) 12 | const match = values.some(Boolean) 13 | return rule.operator === '!=' ? !match : match 14 | }, 15 | } satisfies RuleCheck 16 | -------------------------------------------------------------------------------- /apps/platform/src/rules/NumberRule.ts: -------------------------------------------------------------------------------- 1 | import { RuleCheck, RuleEvalException } from './RuleEngine' 2 | import { compile, queryValue as queryValues } from './RuleHelpers' 3 | 4 | export default { 5 | check({ rule, value }) { 6 | const values = queryValues(value, rule, item => Number(item)) 7 | 8 | if (rule.operator === 'is set') { 9 | return values.length > 0 10 | } 11 | 12 | if (rule.operator === 'is not set') { 13 | return values.length === 0 14 | } 15 | 16 | const ruleValue = compile(rule, item => Number(item)) 17 | 18 | return values.some(v => { 19 | switch (rule.operator) { 20 | case '=': 21 | return v === ruleValue 22 | case '!=': 23 | return v !== ruleValue 24 | case '<': 25 | return v < ruleValue 26 | case '>': 27 | return v > ruleValue 28 | case '<=': 29 | return v <= ruleValue 30 | case '>=': 31 | return v >= ruleValue 32 | default: 33 | throw new RuleEvalException(rule, 'unknown operator: ' + rule.operator) 34 | } 35 | }) 36 | }, 37 | } satisfies RuleCheck 38 | -------------------------------------------------------------------------------- /apps/platform/src/rules/ProjectRulePath.ts: -------------------------------------------------------------------------------- 1 | import Model from '../core/Model' 2 | 3 | export class ProjectRulePath extends Model { 4 | 5 | project_id!: number 6 | path!: string 7 | type!: 'user' | 'event' 8 | name?: string // event name 9 | 10 | } 11 | -------------------------------------------------------------------------------- /apps/platform/src/rules/Rule.ts: -------------------------------------------------------------------------------- 1 | import Model, { ModelParams } from '../core/Model' 2 | 3 | export type Operator = '=' | '!=' | '<' |'<=' | '>' | '>=' | '=' | 'is set' | 'is not set' | 'or' | 'and' | 'xor' | 'empty' | 'contains' | 'not contain' | 'starts with' | 'not start with' | 'ends with' | 'any' | 'none' 4 | export type RuleType = 'wrapper' | 'string' | 'number' | 'boolean' | 'date' | 'array' 5 | export type RuleGroup = 'user' | 'event' | 'parent' 6 | 7 | export type AnyJson = boolean | number | string | null | JsonArray | JsonMap 8 | export interface JsonMap { [key: string]: AnyJson } 9 | export type JsonArray = Array 10 | 11 | export default class Rule extends Model { 12 | project_id!: number 13 | uuid!: string 14 | root_uuid?: string 15 | parent_uuid?: string 16 | type!: RuleType 17 | group!: RuleGroup 18 | path!: string 19 | operator!: Operator 20 | value?: AnyJson 21 | 22 | equals(other: Rule) { 23 | return this.uuid === other.uuid 24 | && this.path === other.path 25 | && this.operator === other.operator 26 | && this.value === other.value 27 | } 28 | } 29 | 30 | export type RuleTree = Omit & { children?: RuleTree[], id?: number } 31 | 32 | export class RuleEvaluation extends Model { 33 | rule_id!: number 34 | user_id!: number 35 | result!: boolean 36 | } 37 | -------------------------------------------------------------------------------- /apps/platform/src/rules/RuleError.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | CompileError: (stack: string) => ({ 3 | message: `A rule contains an invalid Handlebars template (${stack})`, 4 | code: 7000, 5 | statusCode: 422, 6 | }), 7 | } 8 | -------------------------------------------------------------------------------- /apps/platform/src/schema/UserSchemaSyncJob.ts: -------------------------------------------------------------------------------- 1 | import Project from '../projects/Project' 2 | import { Job } from '../queue' 3 | import { syncUserDataPaths } from './UserSchemaService' 4 | 5 | interface UserSchemaSyncTrigger { 6 | project_id?: number 7 | delta?: Date 8 | } 9 | 10 | export default class UserSchemaSyncJob extends Job { 11 | static $name = 'user_schema_sync' 12 | 13 | static from(data: UserSchemaSyncTrigger): UserSchemaSyncJob { 14 | return new this(data) 15 | } 16 | 17 | static async handler({ delta, project_id }: UserSchemaSyncTrigger) { 18 | 19 | if (delta && !(delta instanceof Date)) { 20 | delta = new Date(delta) 21 | } 22 | 23 | // specific project only, or all projects 24 | const projectIds: number[] = project_id 25 | ? [project_id] 26 | : await Project.query().select('id').then(rs => rs.map((r: any) => r.id)) 27 | 28 | for (const project_id of projectIds) { 29 | await syncUserDataPaths({ 30 | project_id, 31 | updatedAfter: delta, 32 | }) 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /apps/platform/src/storage/Image.ts: -------------------------------------------------------------------------------- 1 | import Model from '../core/Model' 2 | import Storage from './Storage' 3 | 4 | export default class Image extends Model { 5 | project_id!: number 6 | uuid!: string 7 | name!: string 8 | original_name!: string 9 | extension!: string 10 | alt!: string 11 | file_size!: number 12 | 13 | get filename(): string { 14 | return `${this.uuid}${this.extension}` 15 | } 16 | 17 | get url(): string { 18 | return Storage.url(this.filename) 19 | } 20 | 21 | toJSON() { 22 | return { 23 | ...this, 24 | url: this.url, 25 | } 26 | } 27 | } 28 | 29 | export interface ImageParams { 30 | name: string 31 | alt?: string 32 | } 33 | -------------------------------------------------------------------------------- /apps/platform/src/storage/ImageService.ts: -------------------------------------------------------------------------------- 1 | import App from '../app' 2 | import { snakeCase } from '../utilities' 3 | import Image, { ImageParams } from './Image' 4 | import { FileStream } from './FileStream' 5 | import { PageParams } from '../core/searchParams' 6 | 7 | export const uploadImage = async (projectId: number, stream: FileStream): Promise => { 8 | const upload = await App.main.storage.save(stream) 9 | return await Image.insertAndFetch({ 10 | project_id: projectId, 11 | name: upload.original_name ? snakeCase(upload.original_name) : '', 12 | ...upload, 13 | }) 14 | } 15 | 16 | export const allImages = async (projectId: number): Promise => { 17 | return await Image.all(qb => qb.where('project_id', projectId)) 18 | } 19 | 20 | export const pagedImages = async (params: PageParams, projectId: number) => { 21 | return await Image.search( 22 | { ...params, fields: ['name'] }, 23 | b => b.where('project_id', projectId), 24 | ) 25 | } 26 | 27 | export const getImage = async (projectId: number, id: number): Promise => { 28 | return await Image.find(id, qb => qb.where('project_id', projectId)) 29 | } 30 | 31 | export const updateImage = async (id: number, params: ImageParams): Promise => { 32 | return await Image.updateAndFetch(id, params) 33 | } 34 | -------------------------------------------------------------------------------- /apps/platform/src/storage/LocalStorageProvider.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import path from 'path' 3 | import { StorageTypeConfig } from './Storage' 4 | import { ImageUploadTask, StorageProvider } from './StorageProvider' 5 | 6 | export interface LocalConfig extends StorageTypeConfig { 7 | driver: 'local' 8 | } 9 | 10 | export class LocalStorageProvider implements StorageProvider { 11 | 12 | path(filename: string) { 13 | return path.join(process.cwd(), 'public', 'uploads', filename) 14 | } 15 | 16 | async upload(task: ImageUploadTask) { 17 | await fs.writeFile( 18 | task.url, 19 | task.stream, 20 | ) 21 | } 22 | 23 | async delete(filename: string): Promise { 24 | await fs.unlink(filename) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/platform/src/storage/StorageError.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | UndefinedStorageMethod: { 3 | message: 'A valid storage method must be defined!', 4 | code: 5000, 5 | }, 6 | NoFilesUploaded: { 7 | message: 'The request contains no files. Please attach a file to upload.', 8 | code: 5001, 9 | }, 10 | BadFormType: { 11 | message: 'Incorrect form type. Please make sure file is being submitted in a multipart form.', 12 | code: 5002, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/src/storage/StorageProvider.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from 'stream' 2 | 3 | export type StorageProviderName = 's3' | 'local' 4 | 5 | export interface ImageUploadTask { 6 | stream: Stream 7 | url: string 8 | } 9 | 10 | export interface StorageProvider { 11 | path(filename: string): string 12 | upload(task: ImageUploadTask): Promise 13 | delete(filename: string): Promise 14 | } 15 | -------------------------------------------------------------------------------- /apps/platform/src/storage/index.ts: -------------------------------------------------------------------------------- 1 | import Image from './Image' 2 | import Storage from './Storage' 3 | 4 | export { Image } 5 | export default Storage 6 | -------------------------------------------------------------------------------- /apps/platform/src/subscriptions/Subscription.ts: -------------------------------------------------------------------------------- 1 | import { ChannelType } from '../config/channels' 2 | import Model, { ModelParams } from '../core/Model' 3 | 4 | export default class Subscription extends Model { 5 | project_id!: number 6 | name!: string 7 | channel!: ChannelType 8 | } 9 | 10 | export enum SubscriptionState { 11 | unsubscribed = 0, 12 | subscribed = 1, 13 | optedIn = 2, 14 | } 15 | 16 | export class UserSubscription extends Model { 17 | subscription_id!: number 18 | user_id!: number 19 | state!: SubscriptionState 20 | name?: string 21 | channel?: string 22 | 23 | static tableName = 'user_subscription' 24 | } 25 | 26 | export type SubscriptionParams = Omit 27 | export type SubscriptionUpdateParams = Pick 28 | -------------------------------------------------------------------------------- /apps/platform/src/subscriptions/SubscriptionError.ts: -------------------------------------------------------------------------------- 1 | import { ErrorType } from 'core/errors' 2 | 3 | export default { 4 | UnsubscribeFailed: { 5 | message: 'Unable to unsubscribe, either the user or subscription type do not exist!', 6 | code: 4000, 7 | }, 8 | UnsubscribeInvalidUser: { 9 | message: 'User does not exist!', 10 | code: 4001, 11 | statusCode: 404, 12 | }, 13 | UnsubscribeInvalidCampaign: { 14 | message: 'Campaign does not exist!', 15 | code: 4002, 16 | statusCode: 404, 17 | }, 18 | } satisfies Record 19 | -------------------------------------------------------------------------------- /apps/platform/src/tags/Tag.ts: -------------------------------------------------------------------------------- 1 | import Model, { ModelParams } from '../core/Model' 2 | 3 | export class Tag extends Model { 4 | project_id!: number 5 | name!: string 6 | } 7 | 8 | export class EntityTag extends Model { 9 | entity!: string // table name 10 | entity_id!: number 11 | tag_id!: number 12 | } 13 | 14 | export type TagParams = Omit 15 | -------------------------------------------------------------------------------- /apps/platform/src/users/UserAliasJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import { aliasUser } from './UserRepository' 3 | import { ClientAliasParams } from '../client/Client' 4 | 5 | type UserAliasTrigger = ClientAliasParams & { 6 | project_id: number 7 | } 8 | 9 | export default class UserAliasJob extends Job { 10 | static $name = 'user_alias' 11 | 12 | static from(data: UserAliasTrigger): UserAliasJob { 13 | return new this(data) 14 | } 15 | 16 | static async handler({ project_id, ...alias }: UserAliasTrigger) { 17 | await aliasUser(project_id, alias) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/platform/src/users/UserDeleteJob.ts: -------------------------------------------------------------------------------- 1 | import App from '../app' 2 | import { Job } from '../queue' 3 | 4 | interface UserDeleteTrigger { 5 | project_id: number 6 | external_id: string 7 | } 8 | 9 | export default class UserDeleteJob extends Job { 10 | static $name = 'user_delete' 11 | 12 | static from(data: UserDeleteTrigger): UserDeleteJob { 13 | return new this(data) 14 | } 15 | 16 | static async handler({ project_id, external_id }: UserDeleteTrigger) { 17 | 18 | await App.main.db.transaction(async trx => 19 | trx('users') 20 | .where({ project_id, external_id }) 21 | .delete(), 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/platform/src/users/UserDeviceJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../queue' 2 | import { saveDevice } from './UserRepository' 3 | import { DeviceParams } from './User' 4 | 5 | type UserDeviceTrigger = DeviceParams & { 6 | project_id: number 7 | } 8 | 9 | export default class UserDeviceJob extends Job { 10 | static $name = 'user_register_device' 11 | 12 | static from(data: UserDeviceTrigger): UserDeviceJob { 13 | return new this(data) 14 | } 15 | 16 | static async handler({ project_id, ...device }: UserDeviceTrigger, job: UserDeviceJob) { 17 | const attempts = job.options.attempts ?? 1 18 | const attemptsMade = job.state.attemptsMade ?? 0 19 | 20 | try { 21 | await saveDevice(project_id, device) 22 | } catch (error) { 23 | if (attemptsMade < (attempts - 1)) throw error 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/platform/src/users/UserError.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | NotFound: { 3 | message: 'No user matching the given criteria was found.', 4 | code: 3000, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /apps/platform/src/users/UserEvent.ts: -------------------------------------------------------------------------------- 1 | import Model from '../core/Model' 2 | 3 | export interface TemplateEvent extends Record { 4 | name: string 5 | } 6 | 7 | export class UserEvent extends Model { 8 | project_id!: number 9 | user_id!: number 10 | name!: string 11 | data!: Record 12 | 13 | static jsonAttributes = ['data'] 14 | 15 | flatten(): TemplateEvent { 16 | return { 17 | ...this.data, 18 | name: this.name, 19 | created_at: this.created_at, 20 | } 21 | } 22 | } 23 | 24 | export type UserEventParams = Pick 25 | -------------------------------------------------------------------------------- /apps/platform/src/worker.ts: -------------------------------------------------------------------------------- 1 | import { loadJobs } from './config/queue' 2 | import scheduler, { Scheduler } from './config/scheduler' 3 | import Queue from './queue' 4 | 5 | export default class Worker { 6 | worker: Queue 7 | scheduler: Scheduler 8 | 9 | constructor( 10 | public app: import('./app').default, 11 | ) { 12 | this.worker = new Queue(app.env.queue) 13 | this.scheduler = scheduler(app) 14 | this.loadJobs() 15 | } 16 | 17 | run() { 18 | this.worker.start() 19 | } 20 | 21 | async close() { 22 | await this.worker.close() 23 | await this.scheduler.close() 24 | } 25 | 26 | loadJobs() { 27 | loadJobs(this.worker) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/platform/tests/setup.ts: -------------------------------------------------------------------------------- 1 | import env from '../src/config/env' 2 | import App from '../src/app' 3 | 4 | jest.mock('ioredis', () => require('ioredis-mock')) 5 | 6 | beforeAll(async () => { 7 | await App.init(env('test')) 8 | }) 9 | -------------------------------------------------------------------------------- /apps/platform/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "baseUrl": "./src", 7 | "outDir": "./build", 8 | "typeRoots": [ 9 | "node_modules/@types", 10 | "@types" 11 | ] 12 | }, 13 | "include": [ 14 | "./src/**/*", 15 | "@types", 16 | "tsconfig.json" 17 | ], 18 | "exclude": [ 19 | "node_modules", 20 | "./**/*.spec.ts", 21 | "./**/__mocks__/*" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /apps/ui/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .DS_Store 3 | .env 4 | node_modules 5 | build -------------------------------------------------------------------------------- /apps/ui/.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_API_BASE_URL=http://localhost:3000/api 2 | REACT_APP_PROXY_URL=http://localhost:3001 -------------------------------------------------------------------------------- /apps/ui/Dockerfile: -------------------------------------------------------------------------------- 1 | # --------------> The compiler image 2 | FROM node:18 AS compile 3 | WORKDIR /usr/src/app/apps/ui 4 | COPY ./tsconfig.base.json /usr/src/app 5 | COPY ./apps/ui ./ 6 | RUN npm ci 7 | RUN npm run build 8 | 9 | # --------------> The production image 10 | FROM nginx:1.23.4-alpine 11 | EXPOSE 3000 12 | COPY --from=compile /usr/src/app/apps/ui/docker/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf 13 | COPY --from=compile /usr/src/app/apps/ui/build /usr/share/nginx/html 14 | COPY --from=compile /usr/src/app/apps/ui/scripts /usr/share/nginx/html 15 | WORKDIR /usr/share/nginx/html 16 | RUN apk add --no-cache bash 17 | RUN chmod +x env.sh 18 | CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""] -------------------------------------------------------------------------------- /apps/ui/config-overrides.js: -------------------------------------------------------------------------------- 1 | module.exports = function override(config) { 2 | return { 3 | ...config, 4 | ignoreWarnings: [ 5 | { 6 | message: /source-map-loader/, 7 | module: /node_modules\/rrule/, 8 | }, 9 | ], 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/ui/docker/nginx/conf.d/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 3000; 3 | root /usr/share/nginx/html; 4 | index index.html; 5 | 6 | location / { 7 | try_files $uri $uri/ /index.html; 8 | } 9 | 10 | location /api { 11 | proxy_pass http://api:3001; 12 | } 13 | 14 | location ~ ^\/(uploads|unsubscribe|\.well-known|(?:c$|c\/)|(?:o$|o\/)) { 15 | rewrite ^\/(.*)$ /api/$1 break; 16 | proxy_pass http://api:3001; 17 | } 18 | 19 | client_max_body_size 64M; 20 | } -------------------------------------------------------------------------------- /apps/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /apps/ui/public/config.js: -------------------------------------------------------------------------------- 1 | window.API_BASE_URL = undefined 2 | -------------------------------------------------------------------------------- /apps/ui/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parcelvoy/platform/3d59d28fff26471b3c3a76aa119290bd3d314ab0/apps/ui/public/favicon-32x32.png -------------------------------------------------------------------------------- /apps/ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parcelvoy/platform/3d59d28fff26471b3c3a76aa119290bd3d314ab0/apps/ui/public/favicon.ico -------------------------------------------------------------------------------- /apps/ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Multi Send Thing!", 3 | "name": "Send so many things", 4 | "icons": [], 5 | "start_url": ".", 6 | "display": "standalone", 7 | "theme_color": "#000000", 8 | "background_color": "#ffffff" 9 | } 10 | -------------------------------------------------------------------------------- /apps/ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /apps/ui/public/safari.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /apps/ui/scripts/env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ENV_JS="./config.js" 4 | 5 | rm -rf ${ENV_JS} 6 | touch ${ENV_JS} 7 | 8 | varname='API_BASE_URL' 9 | value=$(printf '%s\n' "${!varname}") 10 | echo "window.$varname = \"$value\";" >> ${ENV_JS} -------------------------------------------------------------------------------- /apps/ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { RouterProvider } from 'react-router-dom' 3 | import { PreferencesProvider } from './ui/PreferencesContext' 4 | import { RouterProps, createRouter } from './views/router' 5 | import { Toaster } from './ui/Toast' 6 | 7 | export default function App(props: RouterProps) { 8 | 9 | const router = useMemo(() => createRouter(props), [props]) 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /apps/ui/src/assets/logo-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/ui/src/assets/parcelvoylogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parcelvoy/platform/3d59d28fff26471b3c3a76aa119290bd3d314ab0/apps/ui/src/assets/parcelvoylogo.png -------------------------------------------------------------------------------- /apps/ui/src/config/env.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { API_BASE_URL: string } 3 | } 4 | 5 | export const env = { 6 | api: { 7 | baseURL: window.API_BASE_URL 8 | || (process.env.REACT_APP_API_BASE_URL ?? '/api'), 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /apps/ui/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next' 2 | import { initReactI18next } from 'react-i18next' 3 | import backend from 'i18next-http-backend' 4 | 5 | i18n 6 | .use(backend) 7 | .use(initReactI18next) // passes i18n down to react-i18next 8 | .init({ 9 | lng: 'en', 10 | debug: true, 11 | backend: { 12 | loadPath: '/locales/{{lng}}.json', 13 | }, 14 | interpolation: { 15 | escapeValue: false, // react already safes from xss 16 | }, 17 | fallbackLng: 'en', 18 | }).catch(() => {}) 19 | 20 | export default i18n 21 | -------------------------------------------------------------------------------- /apps/ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './i18n' 5 | import reportWebVitals from './reportWebVitals' 6 | 7 | import '@fontsource/inter/400.css' 8 | import '@fontsource/inter/500.css' 9 | import '@fontsource/inter/700.css' 10 | import './variables.css' 11 | import './index.css' 12 | 13 | const root = ReactDOM.createRoot( 14 | document.getElementById('root') as HTMLElement, 15 | ) 16 | root.render( 17 | 18 | 19 | , 20 | ) 21 | 22 | // If you want to start measuring performance in your app, pass a function 23 | // to log results (for example: reportWebVitals(console.log)) 24 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 25 | reportWebVitals() 26 | -------------------------------------------------------------------------------- /apps/ui/src/mod.ts: -------------------------------------------------------------------------------- 1 | export { default as ParcelvoyUI } from './App' 2 | export { default as parcelvoyApi, client as parcelvoyClient } from './api' 3 | export * as Types from './types' 4 | export * from './contexts' 5 | export * from './hooks' 6 | export * from './ui' 7 | export { createStatefulRoute } from './views/createStatefulRoute' 8 | export { LoaderContextProvider, StatefulLoaderContextProvider } from './views/LoaderContextProvider' 9 | -------------------------------------------------------------------------------- /apps/ui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/ui/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals' 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if ((onPerfEntry != null) && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry) 7 | getFID(onPerfEntry) 8 | getFCP(onPerfEntry) 9 | getLCP(onPerfEntry) 10 | getTTFB(onPerfEntry) 11 | }).catch(err => console.error(err)) 12 | } 13 | } 14 | 15 | export default reportWebVitals 16 | -------------------------------------------------------------------------------- /apps/ui/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware') 2 | 3 | module.exports = function(app) { 4 | app.use( 5 | '/api', 6 | createProxyMiddleware({ 7 | target: process.env.REACT_APP_PROXY_URL, 8 | changeOrigin: true, 9 | }), 10 | ) 11 | app.use( 12 | '/unsubscribe', 13 | createProxyMiddleware({ 14 | pathRewrite: (path) => { 15 | return path.replace('/unsubscribe', '/api/unsubscribe') 16 | }, 17 | target: process.env.REACT_APP_PROXY_URL, 18 | changeOrigin: true, 19 | }), 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /apps/ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | -------------------------------------------------------------------------------- /apps/ui/src/ui/Alert.css: -------------------------------------------------------------------------------- 1 | .ui-alert { 2 | padding: 20px; 3 | border-radius: var(--border-radius); 4 | margin-bottom: 5px; 5 | background: var(--color-grey-soft); 6 | color: var(--color-primary); 7 | } 8 | 9 | .ui-alert h4 { 10 | margin: 0; 11 | } 12 | 13 | .ui-alert p { 14 | margin: 5px 0; 15 | } 16 | 17 | .ui-alert .alert-actions { 18 | margin-top: 10px; 19 | } 20 | 21 | .ui-alert.info { 22 | background: var(--color-blue-soft); 23 | color: var(--color-blue-hard); 24 | } 25 | 26 | .ui-alert.success { 27 | background: var(--color-green-soft); 28 | color: var(--color-green-hard); 29 | } 30 | 31 | .ui-alert.error { 32 | background: var(--color-red-soft); 33 | color: var(--color-red-hard); 34 | } 35 | 36 | .ui-alert.warn { 37 | background: var(--color-yellow-soft); 38 | color: var(--color-yellow-hard); 39 | } -------------------------------------------------------------------------------- /apps/ui/src/ui/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, PropsWithChildren } from 'react' 2 | import './Alert.css' 3 | 4 | export interface AlertProps extends PropsWithChildren { 5 | variant?: 'info' | 'plain' | 'success' | 'error' | 'warn' 6 | title: React.ReactNode 7 | body?: React.ReactNode 8 | actions?: React.ReactNode 9 | style?: CSSProperties 10 | } 11 | 12 | export default function Alert(props: AlertProps) { 13 | return ( 14 |
15 |

{props.title}

16 |

{props.body ?? props.children}

17 | {props.actions &&
{props.actions}
} 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /apps/ui/src/ui/ButtonGroup.css: -------------------------------------------------------------------------------- 1 | .ui-button-group { 2 | display: flex; 3 | box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05); 4 | border-radius: var(--border-radius); 5 | } 6 | 7 | .ui-button-group .ui-button, 8 | .ui-button-group .ui-select .select-button, 9 | .ui-button-group .ui-text-input input { 10 | box-shadow: none; 11 | margin: 0; 12 | } 13 | 14 | .ui-button-group .ui-button:not(:last-child), 15 | .ui-button-group .ui-select:not(:last-child) .select-button, 16 | .ui-button-group .ui-text-input:not(:last-child) input { 17 | border-top-right-radius: 0; 18 | border-bottom-right-radius: 0; 19 | } 20 | 21 | .ui-button-group .ui-button:not(:first-child), 22 | .ui-button-group .ui-select:not(:first-child) .select-button, 23 | .ui-button-group .ui-text-input:not(:first-child) input { 24 | border-top-left-radius: 0; 25 | border-bottom-left-radius: 0; 26 | 27 | } 28 | 29 | .ui-button-group .ui-button:not(:first-child) { 30 | margin-left: -1px; 31 | } 32 | 33 | .ui-button-group .ui-select:not(:first-child), 34 | .ui-button-group .ui-text-input:not(:first-child) { 35 | margin-left: -1px; 36 | } -------------------------------------------------------------------------------- /apps/ui/src/ui/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { CSSProperties, PropsWithChildren } from 'react' 3 | import './ButtonGroup.css' 4 | 5 | type ButtonGroupProps = PropsWithChildren<{ 6 | className?: string 7 | style?: CSSProperties 8 | }> 9 | 10 | export default function ButtonGroup({ children, className, style }: ButtonGroupProps) { 11 | return ( 12 |
13 | {children} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /apps/ui/src/ui/CodeExample.css: -------------------------------------------------------------------------------- 1 | .code-example { 2 | position: relative; 3 | } 4 | 5 | .code-example pre { 6 | border-radius: var(--border-radius); 7 | background-color: var(--color-grey-soft); 8 | word-break: keep-all; 9 | overflow-wrap: break-word; 10 | overflow: hidden; 11 | padding: 20px; 12 | margin: 0; 13 | } 14 | 15 | .code-example pre code { 16 | background-color: transparent; 17 | } 18 | 19 | .code-example .copy-button { 20 | position: absolute; 21 | right: 10px; 22 | top: 10px; 23 | } 24 | -------------------------------------------------------------------------------- /apps/ui/src/ui/Columns.css: -------------------------------------------------------------------------------- 1 | .ui-columns { 2 | --spacing: 20px; 3 | 4 | display: flex; 5 | flex-direction: row; 6 | flex-wrap: wrap; 7 | flex-grow: 1; 8 | gap: var(--spacing); 9 | } 10 | 11 | .ui-columns > .ui-column { 12 | flex-basis: 0; 13 | flex-grow: 1; 14 | } 15 | 16 | .ui-column.fullscreen { 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | 21 | @media only screen and (max-width: 600px) { 22 | .ui-columns { 23 | flex-direction: column; 24 | } 25 | } -------------------------------------------------------------------------------- /apps/ui/src/ui/Columns.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, PropsWithChildren } from 'react' 2 | import './Columns.css' 3 | import clsx from 'clsx' 4 | 5 | interface ColumnsProps { 6 | children?: React.ReactNode 7 | } 8 | 9 | interface ColumnProps extends PropsWithChildren<{ style?: CSSProperties }> { 10 | fullscreen?: boolean 11 | } 12 | 13 | export function Columns(props: ColumnsProps) { 14 | return ( 15 |
16 | {props.children} 17 |
18 | ) 19 | } 20 | 21 | export function Column({ children, style, fullscreen }: ColumnProps) { 22 | return ( 23 |
24 | {children} 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /apps/ui/src/ui/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, ReactNode } from 'react' 2 | import Button from './Button' 3 | import Modal from './Modal' 4 | 5 | export interface DialogProps { 6 | open: boolean 7 | onClose: (isOpen: boolean) => void 8 | title: ReactNode 9 | actions?: ReactNode 10 | } 11 | 12 | export default function Dialog({ children, open, onClose, title, actions }: PropsWithChildren) { 13 | return onClose(false)} 16 | title={title} 17 | actions={actions ?? } 18 | size="small"> 19 | {children} 20 | 21 | } 22 | -------------------------------------------------------------------------------- /apps/ui/src/ui/Heading.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | margin: 20px 0; 6 | } 7 | 8 | .heading .heading-text { 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | 13 | .heading h2, .heading h3, .heading h4 { 14 | margin: 0; 15 | flex-shrink: 0; 16 | } 17 | 18 | .heading .actions { 19 | display: flex; 20 | justify-content: flex-end; 21 | align-self: start; 22 | gap: 10px; 23 | } 24 | 25 | .heading .desc { 26 | grid-area: desc; 27 | margin: 5px 0 0; 28 | } 29 | 30 | .heading.heading-h2 { 31 | margin: 0; 32 | padding: 20px 0 15px; 33 | } 34 | 35 | .heading.heading-h3 { 36 | margin: 20px 0 10px; 37 | } 38 | 39 | .heading.heading-h3 .actions { 40 | margin: -5px 0; 41 | } 42 | 43 | .heading.heading-h4 { 44 | margin: 10px 0; 45 | } 46 | 47 | .heading.heading-h4 h4 { 48 | padding: 5px 0; 49 | } 50 | 51 | .heading label, .heading input { 52 | margin: 0; 53 | } -------------------------------------------------------------------------------- /apps/ui/src/ui/Heading.tsx: -------------------------------------------------------------------------------- 1 | import './Heading.css' 2 | 3 | interface HeadingProps { 4 | title: React.ReactNode 5 | size?: 'h2' | 'h3' | 'h4' 6 | actions?: React.ReactNode 7 | children?: React.ReactNode 8 | } 9 | 10 | export default function Heading({ title, actions, children, size = 'h2' }: HeadingProps) { 11 | const HeadingTitle = `${size}` as keyof JSX.IntrinsicElements 12 | return ( 13 |
14 |
15 | {title} 16 | {children &&
{children}
} 17 |
18 | {actions &&
{actions}
} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /apps/ui/src/ui/Iframe.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | interface IframeProps { 4 | content: string 5 | fullHeight?: boolean 6 | } 7 | 8 | export default function Iframe({ content, fullHeight = false }: IframeProps) { 9 | const ref = useRef(null) 10 | 11 | const setBody = () => { 12 | const frame = ref.current 13 | if (frame) { 14 | if (frame.contentDocument?.body) { 15 | frame.contentDocument.body.innerHTML = content 16 | } 17 | if (fullHeight) { 18 | frame.style.minHeight = `${frame.contentWindow?.document.documentElement.scrollHeight}px` 19 | } 20 | } 21 | } 22 | 23 | useEffect(() => setBody(), [content]) 24 | 25 | return ( 26 |