├── .air.toml ├── .dockerignore ├── .env.dev ├── .gitattributes ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .kamal └── hooks │ ├── post-deploy.sample │ ├── post-traefik-reboot.sample │ ├── pre-build.sample │ ├── pre-connect.sample │ ├── pre-deploy.sample │ └── pre-traefik-reboot.sample ├── .vscode ├── extensions.json ├── settings.json └── tailwind.json ├── Dockerfile ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── build.mjs ├── cmd ├── seed │ └── main.go ├── web │ └── main.go └── worker │ └── main.go ├── config ├── config.go ├── config.yaml ├── config_test.go └── firewalls │ ├── accessories.sh │ ├── web-app.sh │ └── worker.sh ├── deploy └── kamal │ └── deploy.yml ├── docker-compose.yml ├── e2e_tests ├── config.js ├── package-lock.json ├── package.json ├── playwright.config.ts └── tests │ ├── goship.spec.ts │ └── helper.ts ├── ent ├── client.go ├── emailsubscription.go ├── emailsubscription │ ├── emailsubscription.go │ └── where.go ├── emailsubscription_create.go ├── emailsubscription_delete.go ├── emailsubscription_query.go ├── emailsubscription_update.go ├── emailsubscriptiontype.go ├── emailsubscriptiontype │ ├── emailsubscriptiontype.go │ └── where.go ├── emailsubscriptiontype_create.go ├── emailsubscriptiontype_delete.go ├── emailsubscriptiontype_query.go ├── emailsubscriptiontype_update.go ├── emojis.go ├── emojis │ ├── emojis.go │ └── where.go ├── emojis_create.go ├── emojis_delete.go ├── emojis_query.go ├── emojis_update.go ├── ent.go ├── enttest │ └── enttest.go ├── fcmsubscriptions.go ├── fcmsubscriptions │ ├── fcmsubscriptions.go │ └── where.go ├── fcmsubscriptions_create.go ├── fcmsubscriptions_delete.go ├── fcmsubscriptions_query.go ├── fcmsubscriptions_update.go ├── filestorage.go ├── filestorage │ ├── filestorage.go │ └── where.go ├── filestorage_create.go ├── filestorage_delete.go ├── filestorage_query.go ├── filestorage_update.go ├── generate.go ├── hook │ ├── custom_hook.go │ └── hook.go ├── image.go ├── image │ ├── image.go │ └── where.go ├── image_create.go ├── image_delete.go ├── image_query.go ├── image_update.go ├── imagesize.go ├── imagesize │ ├── imagesize.go │ └── where.go ├── imagesize_create.go ├── imagesize_delete.go ├── imagesize_query.go ├── imagesize_update.go ├── invitation.go ├── invitation │ ├── invitation.go │ └── where.go ├── invitation_create.go ├── invitation_delete.go ├── invitation_query.go ├── invitation_update.go ├── lastseenonline.go ├── lastseenonline │ ├── lastseenonline.go │ └── where.go ├── lastseenonline_create.go ├── lastseenonline_delete.go ├── lastseenonline_query.go ├── lastseenonline_update.go ├── migrate │ ├── migrate.go │ ├── migrations │ │ ├── 20240903200944.sql │ │ ├── 20240907154749.sql │ │ ├── 20240907155651.sql │ │ ├── 20240907160557.sql │ │ ├── 20240907214903.sql │ │ └── atlas.sum │ └── schema.go ├── monthlysubscription.go ├── monthlysubscription │ ├── monthlysubscription.go │ └── where.go ├── monthlysubscription_create.go ├── monthlysubscription_delete.go ├── monthlysubscription_query.go ├── monthlysubscription_update.go ├── mutation.go ├── notification.go ├── notification │ ├── notification.go │ └── where.go ├── notification_create.go ├── notification_delete.go ├── notification_query.go ├── notification_update.go ├── notificationpermission.go ├── notificationpermission │ ├── notificationpermission.go │ └── where.go ├── notificationpermission_create.go ├── notificationpermission_delete.go ├── notificationpermission_query.go ├── notificationpermission_update.go ├── notificationtime.go ├── notificationtime │ ├── notificationtime.go │ └── where.go ├── notificationtime_create.go ├── notificationtime_delete.go ├── notificationtime_query.go ├── notificationtime_update.go ├── passwordtoken.go ├── passwordtoken │ ├── passwordtoken.go │ └── where.go ├── passwordtoken_create.go ├── passwordtoken_delete.go ├── passwordtoken_query.go ├── passwordtoken_update.go ├── phoneverificationcode.go ├── phoneverificationcode │ ├── phoneverificationcode.go │ └── where.go ├── phoneverificationcode_create.go ├── phoneverificationcode_delete.go ├── phoneverificationcode_query.go ├── phoneverificationcode_update.go ├── predicate │ └── predicate.go ├── profile.go ├── profile │ ├── profile.go │ └── where.go ├── profile_create.go ├── profile_delete.go ├── profile_query.go ├── profile_update.go ├── pwapushsubscription.go ├── pwapushsubscription │ ├── pwapushsubscription.go │ └── where.go ├── pwapushsubscription_create.go ├── pwapushsubscription_delete.go ├── pwapushsubscription_query.go ├── pwapushsubscription_update.go ├── runtime.go ├── runtime │ └── runtime.go ├── schema │ ├── emailsubscription.go │ ├── emailsubscriptiontype.go │ ├── emojis.go │ ├── fcmsubscriptions.go │ ├── filestorage.go │ ├── image.go │ ├── imagesize.go │ ├── invitation.go │ ├── lastseenonline.go │ ├── mixin.go │ ├── monthlysubscription.go │ ├── notification.go │ ├── notificationpermission.go │ ├── notificationtime.go │ ├── passwordtoken.go │ ├── phoneverificationcode.go │ ├── profile.go │ ├── pwapushsubscription.go │ ├── sentemail.go │ └── user.go ├── sentemail.go ├── sentemail │ ├── sentemail.go │ └── where.go ├── sentemail_create.go ├── sentemail_delete.go ├── sentemail_query.go ├── sentemail_update.go ├── tx.go ├── user.go ├── user │ ├── user.go │ └── where.go ├── user_create.go ├── user_delete.go ├── user_query.go └── user_update.go ├── entrypoint.sh ├── go.mod ├── go.sum ├── javascript ├── svelte │ ├── components │ │ ├── MultiSelectComponent.svelte │ │ ├── NotificationPermissions.svelte │ │ ├── PhoneNumberPicker.svelte │ │ ├── PhotoUploader.svelte │ │ ├── PwaInstallButton.svelte │ │ ├── PwaSubscribePushChecker.svelte │ │ ├── SingleSelect.svelte │ │ ├── SvelteTodoComponent.svelte │ │ ├── ThemeToggle.svelte │ │ ├── notifications │ │ │ ├── EmailSubscribe.svelte │ │ │ ├── IOSSubscribePush.svelte │ │ │ ├── IndividualNotificationPermission.svelte │ │ │ ├── PermissionButton.svelte │ │ │ ├── PwaSubscribePush.svelte │ │ │ ├── SmsSubscribe.svelte │ │ │ ├── _NotificationPermission.svelte │ │ │ └── icons │ │ │ │ ├── EmailDisabledIcon.svelte │ │ │ │ ├── EmailEnabledIcon.svelte │ │ │ │ ├── LoadingSpinner.svelte │ │ │ │ ├── PushDisabledIcon.svelte │ │ │ │ ├── PushEnabledIcon.svelte │ │ │ │ ├── SmsDisabledIcon.svelte │ │ │ │ └── SmsEnabledIcon.svelte │ │ └── utils.js │ └── main.js └── vanilla │ ├── cal_heatmap.js │ ├── load_scripts_and_styles.js │ ├── main.js │ └── test_quiz.js ├── package-lock.json ├── package.json ├── pgvector-image ├── Dockerfile └── init_pgvector.sh ├── pkg ├── context │ ├── context.go │ └── context_test.go ├── controller │ ├── controller.go │ ├── controller_test.go │ ├── form.go │ ├── form_test.go │ ├── page.go │ ├── page_test.go │ ├── pager.go │ └── pager_test.go ├── domain │ ├── constants.go │ ├── enum.go │ ├── maps.go │ └── struct.go ├── funcmap │ ├── funcmap.go │ └── funcmap_test.go ├── htmx │ ├── htmx.go │ └── htmx_test.go ├── middleware │ ├── auth.go │ ├── auth_test.go │ ├── cache.go │ ├── cache_test.go │ ├── device.go │ ├── entity.go │ ├── entity_test.go │ ├── lastseenonline.go │ ├── log.go │ ├── log_test.go │ ├── middleware_test.go │ ├── onboarding.go │ └── sentry.go ├── repos │ ├── emailsmanager │ │ ├── emailsubscriptionrepo.go │ │ ├── emailsubscriptionrepo_test.go │ │ ├── update_email.go │ │ └── update_email_test.go │ ├── mailer │ │ ├── mailer.go │ │ ├── resend.go │ │ └── smtp.go │ ├── msg │ │ ├── msg.go │ │ └── msg_test.go │ ├── notifierrepo │ │ ├── notificationstoragerepo.go │ │ ├── notificationstoragerepo_test.go │ │ ├── notifier.go │ │ ├── notifier_test.go │ │ ├── permissions.go │ │ ├── permissions_test.go │ │ ├── planned_notifications.go │ │ ├── planned_notifications_test.go │ │ ├── push_notifications_fcm.go │ │ ├── push_notifications_fcm_test.go │ │ ├── push_notifications_pwa.go │ │ └── sms.go │ ├── profilerepo │ │ ├── profilerepo.go │ │ └── profilerepo_test.go │ ├── pubsub │ │ └── pubsub.go │ ├── storage │ │ ├── mocks.go │ │ └── storagerepo.go │ └── subscriptions │ │ ├── subscriptions.go │ │ └── subscriptions_test.go ├── routing │ ├── routenames │ │ └── routenames.go │ └── routes │ │ ├── about.go │ │ ├── about_test.go │ │ ├── clear_site_cookie.go │ │ ├── contact.go │ │ ├── delete_account.go │ │ ├── docs.go │ │ ├── email_subscribe.go │ │ ├── error.go │ │ ├── forgot_password.go │ │ ├── healthcheck.go │ │ ├── helpers.go │ │ ├── home.go │ │ ├── home_feed.go │ │ ├── install_app.go │ │ ├── landing.go │ │ ├── login.go │ │ ├── logout.go │ │ ├── notifications.go │ │ ├── payments.go │ │ ├── preferences.go │ │ ├── privacy.go │ │ ├── profile.go │ │ ├── profile_photo.go │ │ ├── push_notifs.go │ │ ├── realtime.go │ │ ├── register.go │ │ ├── register_test.go │ │ ├── reset_password.go │ │ ├── router.go │ │ ├── routes_test.go │ │ ├── upload_photo.go │ │ ├── verify_email.go │ │ └── verify_email_subscription.go ├── services │ ├── auth.go │ ├── auth_test.go │ ├── cache.go │ ├── cache_test.go │ ├── container.go │ ├── container_test.go │ ├── services_test.go │ ├── tasks.go │ ├── tasks_test.go │ ├── validator.go │ └── validator_test.go ├── tasks │ ├── mail.go │ ├── notifications.go │ └── subscriptions.go ├── tests │ └── tests.go └── types │ ├── about.go │ ├── committed.go │ ├── contact.go │ ├── email_subscribe.go │ ├── emails.go │ ├── forgot_password.go │ ├── home.go │ ├── home_feed.go │ ├── invitations.go │ ├── landing_page.go │ ├── login.go │ ├── notifications.go │ ├── page_data.go │ ├── payments.go │ ├── preferences.go │ ├── profile.go │ ├── register.go │ ├── reset_password.go │ └── search.go ├── pwabuilder-ios-wrapper ├── next-steps.html └── src │ ├── .gitignore │ ├── Cherie.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Cherie.xcscheme │ ├── Cherie.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ ├── Cherie │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── 100.png │ │ │ ├── 1024.png │ │ │ ├── 114.png │ │ │ ├── 120.png │ │ │ ├── 128.png │ │ │ ├── 144.png │ │ │ ├── 152.png │ │ │ ├── 16.png │ │ │ ├── 167.png │ │ │ ├── 180.png │ │ │ ├── 20.png │ │ │ ├── 256.png │ │ │ ├── 29.png │ │ │ ├── 32.png │ │ │ ├── 40.png │ │ │ ├── 50.png │ │ │ ├── 512.png │ │ │ ├── 57.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 64.png │ │ │ ├── 72.png │ │ │ ├── 76.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Image.imageset │ │ │ └── Contents.json │ │ └── LaunchIcon.imageset │ │ │ ├── Contents.json │ │ │ ├── launch-128.png │ │ │ ├── launch-192.png │ │ │ └── launch-64.png │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Entitlements │ │ ├── .gitignore │ │ └── Entitlements.plist │ ├── GoogleService-Info.plist │ ├── IAP.swift │ ├── Info.plist │ ├── Printer.swift │ ├── PushNotifications.swift │ ├── SceneDelegate.swift │ ├── Settings.swift │ ├── ViewController.swift │ └── WebView.swift │ ├── LICENSE │ ├── Podfile │ ├── launch-128.png │ ├── launch-192.png │ ├── launch-256.png │ ├── launch-512.png │ └── launch-64.png ├── scripts ├── regen_logo_images.py └── requirements.txt ├── seeder ├── seeder.go └── seeder_test.go ├── service-worker.js ├── static ├── cherie_pwa_apple-icon-180.png ├── cherie_pwa_manifest-icon-192.maskable.png ├── cherie_pwa_manifest-icon-512.maskable.png ├── cherie_pwa_manifest-icon-96.maskable.png ├── favicon.png ├── icon.png ├── main.css ├── main.css.map ├── main.js ├── main.js.map ├── manifest.json ├── meta_main.json ├── meta_svelte_bundle.json ├── meta_vanilla_bundle.json ├── styles_bundle.css ├── svelte_bundle.css ├── svelte_bundle.css.map ├── svelte_bundle.js ├── svelte_bundle.js.map ├── vanilla_bundle.js └── vanilla_bundle.js.map ├── styles ├── styles.css └── tailwind_components.css ├── tailwind.config.js ├── templates ├── components │ ├── accordion.templ │ ├── accordion_templ.go │ ├── auth.templ │ ├── auth_templ.go │ ├── bottom_nav.templ │ ├── bottom_nav_templ.go │ ├── core.templ │ ├── core_templ.go │ ├── documentation.templ │ ├── documentation_templ.go │ ├── drawer.templ │ ├── drawer_templ.go │ ├── empty_page_msg.templ │ ├── empty_page_msg_templ.go │ ├── forms.templ │ ├── forms_templ.go │ ├── heatmap.templ │ ├── heatmap_templ.go │ ├── icons.templ │ ├── icons_templ.go │ ├── loading.templ │ ├── loading_templ.go │ ├── logos.templ │ ├── logos_templ.go │ ├── messages.templ │ ├── messages_templ.go │ ├── navbar.templ │ ├── navbar_templ.go │ ├── payments.templ │ ├── payments_templ.go │ ├── permissions.templ │ ├── permissions_templ.go │ ├── prev_nav.templ │ ├── prev_nav_templ.go │ ├── profile.templ │ ├── profile_templ.go │ ├── pwa_install.templ │ ├── pwa_install_templ.go │ ├── sidebar.templ │ ├── sidebar_templ.go │ ├── theme_toggle.templ │ ├── theme_toggle_templ.go │ ├── tooltip.templ │ ├── tooltip_templ.go │ ├── top_banner.templ │ └── top_banner_templ.go ├── emails │ ├── password_reset.templ │ ├── password_reset_templ.go │ ├── registration_confirmation.templ │ ├── registration_confirmation_templ.go │ ├── subscription_confirmation.templ │ ├── subscription_confirmation_templ.go │ ├── test.templ │ ├── test_templ.go │ ├── update.templ │ └── update_templ.go ├── helpers │ ├── helpers.templ │ └── helpers_templ.go ├── layouts │ ├── auth.templ │ ├── auth_templ.go │ ├── documentation.templ │ ├── documentation_templ.go │ ├── email.templ │ ├── email_templ.go │ ├── landing_page.templ │ ├── landing_page_templ.go │ ├── main.templ │ └── main_templ.go ├── pages │ ├── about.templ │ ├── about_templ.go │ ├── contact.templ │ ├── contact_templ.go │ ├── delete_account.templ │ ├── delete_account_templ.go │ ├── docs_architecture.templ │ ├── docs_architecture_templ.go │ ├── docs_emails.templ │ ├── docs_emails_templ.go │ ├── documentation.templ │ ├── documentation_templ.go │ ├── email_subscribe.templ │ ├── email_subscribe_templ.go │ ├── error.templ │ ├── error_templ.go │ ├── forgot_password.templ │ ├── forgot_password_templ.go │ ├── healthcheck.templ │ ├── healthcheck_templ.go │ ├── home.templ │ ├── home_feed.templ │ ├── home_feed_templ.go │ ├── home_templ.go │ ├── install_app.templ │ ├── install_app_templ.go │ ├── invitations.templ │ ├── invitations_templ.go │ ├── landing_page.templ │ ├── landing_page_templ.go │ ├── login.templ │ ├── login_templ.go │ ├── notifications.templ │ ├── notifications_templ.go │ ├── payments.templ │ ├── payments_templ.go │ ├── phone.templ │ ├── phone_templ.go │ ├── preferences.templ │ ├── preferences_templ.go │ ├── privacy_policy.templ │ ├── privacy_policy_templ.go │ ├── profile.templ │ ├── profile_templ.go │ ├── register.templ │ ├── register_templ.go │ ├── reset_password.templ │ └── reset_password_templ.go └── templates.go ├── testdata └── photos │ ├── 1.jpg │ └── 2.jpg └── tsconfig.json /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | exclude_dir = [ 7 | "assets", 8 | "tmp", 9 | "vendor", 10 | "testdata", 11 | "e2e_tests", 12 | "node_modules", 13 | "javascript", 14 | "pwabuilder-ios-wrapper", 15 | "pwabuilder-android-wrapper", 16 | "schemaspy-output", 17 | "scripts", 18 | 19 | ] 20 | args_bin = [] 21 | bin = "./tmp/main" 22 | cmd = "templ generate && go build -o ./tmp/main cmd/web/main.go" 23 | delay = 1000 24 | exclude_file = [] 25 | exclude_regex = [".*_templ.go", "_test.go"] 26 | exclude_unchanged = false 27 | follow_symlink = false 28 | full_bin = "" 29 | include_dir = [] 30 | include_ext = ["go", "tpl", "tmpl", "templ", "html"] 31 | include_file = [] 32 | kill_delay = "0s" 33 | log = "build-errors.log" 34 | poll = false 35 | poll_interval = 0 36 | post_cmd = [] 37 | pre_cmd = [] 38 | rerun = false 39 | rerun_delay = 500 40 | send_interrupt = false 41 | stop_on_error = false 42 | 43 | [color] 44 | app = "" 45 | build = "yellow" 46 | main = "magenta" 47 | runner = "green" 48 | watcher = "cyan" 49 | 50 | [log] 51 | main_only = false 52 | time = false 53 | 54 | [misc] 55 | clean_on_exit = false 56 | 57 | [screen] 58 | clear_on_rebuild = false 59 | keep_scroll = true 60 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env* 2 | .air* 3 | .gitignore 4 | *.md 5 | Dockerfile 6 | LICENSE 7 | Makefile 8 | tailwind* 9 | *.csv 10 | .github 11 | .kamal 12 | schemaspy* 13 | testdata 14 | tmp -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | ####################################### 2 | # NOTE: 3 | # For production, copy-paste this file to `./.env` and make sure not to commit it 4 | # to your git repo...except if you want to one day regret it lol. 5 | # 6 | # If some env vars are not being pushed by kamal to the containers, it is likely because they 7 | # have not been added to the "secret" section of the kamal config (./deploy.yaml) 8 | ####################################### 9 | 10 | KAMAL_REGISTRY_USERNAME=username 11 | KAMAL_REGISTRY_PASSWORD=ghp_123 12 | 13 | # TODO: set the below env var 14 | PAGODA_APP_ENCRYPTIONKEY=abcdefghijklmnopqrstuv 15 | 16 | # PAGODA_DATABASE_HOSTNAME= 17 | # PAGODA_DATABASE_USER= 18 | # PAGODA_DATABASE_PASSWORD= 19 | PAGODA_DATABASE_DATABASE=postgres 20 | 21 | PAGODA_MAIL_RESENDAPIKEY=re_123 22 | 23 | PAGODA_STORAGE_S3ACCESSKEY=123 24 | PAGODA_STORAGE_S3SECRETKEY=123 25 | 26 | # For DB Backups 27 | S3_ACCESS_KEY_ID=123 28 | S3_SECRET_ACCESS_KEY=123 29 | 30 | # Sentry 31 | PAGODA_APP_SENTRYDSN=FILL 32 | 33 | # VAPID 34 | PAGODA_APP_VAPIDPUBLICKEY= 35 | PAGODA_APP_VAPIDPRIVATEKEY=pZ-FILL 36 | 37 | # Stripe 38 | PAGODA_APP_PUBLICSTRIPEKEY=pk_ 39 | PAGODA_APP_PRIVATESTRIPEKEY=sk_ 40 | PAGODA_APP_STRIPEWEBHOOKSECRET=whsec_ 41 | 42 | # E3Kit 43 | PAGODA_APP_E3KIT_APPID=2132131 44 | PAGODA_APP_E3KIT_APPKEYID=12313 45 | PAGODA_APP_E3KIT_APPKEY=123143 46 | 47 | # Generate an AEAD keyset using the AES256-GCM key template: 48 | # $ tinkey create-keyset --key-template AES256_GCM --out-format JSON --out tmp/keyset.json 49 | # Convert the Keyset to Base64 50 | # $ cat tmp/keyset.json | jq -c .key | base64 51 | PAGODA_APP_APPENCRYPTIONKEY="FILL" 52 | 53 | # Only for dev for docker-compose 54 | HOST_CACHE_PORT=6379 55 | HOST_DB_PORT=5432 56 | HOST_MAILPIT_HTTP_PORT=8025 57 | HOST_MAILPIT_SMTP_PORT=1025 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | static/* -diff 2 | *_templ.go -diff -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | # TODO: Earthly (which I've used before) can be used to create platform-agnostic CI tests, 4 | # in case we want to move away from Github seamlessly. 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | backend_tests: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: 1.22.2 21 | 22 | - uses: actions/cache@v3 23 | with: 24 | path: | 25 | ~/.cache/go-build 26 | ~/go/pkg/mod 27 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-${{ github.run_id }} 28 | restore-keys: | 29 | ${{ runner.os }}-go- 30 | 31 | - name: Start containers 32 | run: | 33 | docker compose up -d 34 | sleep 3 35 | 36 | - name: Test 37 | run: go test -p 1 ./... 38 | 39 | # TODO: these were never set up in the CI 40 | # e2e_tests: 41 | # needs: backend_tests 42 | # runs-on: ubuntu-latest 43 | # steps: 44 | # - uses: actions/checkout@v3 45 | 46 | # - name: Set up Node.js 47 | # uses: actions/setup-node@v3 48 | # with: 49 | # node-version: 18 50 | 51 | # - name: Set up Go 52 | # uses: actions/setup-go@v3 53 | # with: 54 | # go-version: 1.21.3 55 | 56 | # - name: Check if app is running 57 | # run: | 58 | # until curl --output /dev/null --silent --head --fail http://localhost:8000; do 59 | # echo "Waiting for app to be up..." 60 | # sleep 5 61 | # done 62 | 63 | # - name: Install dependencies 64 | # run: npm ci 65 | # working-directory: ./e2e_tests 66 | 67 | # - name: Install Playwright Browsers 68 | # run: npx playwright install --with-deps 69 | # working-directory: ./e2e_tests 70 | 71 | # - name: Seed Database 72 | # run: make seed 73 | 74 | # - name: Run app 75 | # run: make watch 76 | 77 | # - name: Run Playwright tests 78 | # run: npx playwright test 79 | # working-directory: ./e2e_tests 80 | 81 | # - uses: actions/upload-artifact@v3 82 | # if: always() 83 | # with: 84 | # name: playwright-report 85 | # path: playwright-report/ 86 | # retention-days: 1 87 | -------------------------------------------------------------------------------- /.kamal/hooks/post-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample post-deploy hook 4 | # 5 | # These environment variables are available: 6 | # KAMAL_RECORDED_AT 7 | # KAMAL_PERFORMER 8 | # KAMAL_VERSION 9 | # KAMAL_HOSTS 10 | # KAMAL_ROLE (if set) 11 | # KAMAL_DESTINATION (if set) 12 | # KAMAL_RUNTIME 13 | 14 | echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" 15 | -------------------------------------------------------------------------------- /.kamal/hooks/post-traefik-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooted Traefik on $KAMAL_HOSTS" 4 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-build.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample pre-build hook 4 | # 5 | # Checks: 6 | # 1. We have a clean checkout 7 | # 2. A remote is configured 8 | # 3. The branch has been pushed to the remote 9 | # 4. The version we are deploying matches the remote 10 | # 11 | # These environment variables are available: 12 | # KAMAL_RECORDED_AT 13 | # KAMAL_PERFORMER 14 | # KAMAL_VERSION 15 | # KAMAL_HOSTS 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | if [ -n "$(git status --porcelain)" ]; then 20 | echo "Git checkout is not clean, aborting..." >&2 21 | git status --porcelain >&2 22 | exit 1 23 | fi 24 | 25 | first_remote=$(git remote) 26 | 27 | if [ -z "$first_remote" ]; then 28 | echo "No git remote set, aborting..." >&2 29 | exit 1 30 | fi 31 | 32 | current_branch=$(git branch --show-current) 33 | 34 | if [ -z "$current_branch" ]; then 35 | echo "Not on a git branch, aborting..." >&2 36 | exit 1 37 | fi 38 | 39 | remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) 40 | 41 | if [ -z "$remote_head" ]; then 42 | echo "Branch not pushed to remote, aborting..." >&2 43 | exit 1 44 | fi 45 | 46 | if [ "$KAMAL_VERSION" != "$remote_head" ]; then 47 | echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 48 | exit 1 49 | fi 50 | 51 | exit 0 52 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-connect.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-connect check 4 | # 5 | # Warms DNS before connecting to hosts in parallel 6 | # 7 | # These environment variables are available: 8 | # KAMAL_RECORDED_AT 9 | # KAMAL_PERFORMER 10 | # KAMAL_VERSION 11 | # KAMAL_HOSTS 12 | # KAMAL_ROLE (if set) 13 | # KAMAL_DESTINATION (if set) 14 | # KAMAL_RUNTIME 15 | 16 | hosts = ENV["KAMAL_HOSTS"].split(",") 17 | results = nil 18 | max = 3 19 | 20 | elapsed = Benchmark.realtime do 21 | results = hosts.map do |host| 22 | Thread.new do 23 | tries = 1 24 | 25 | begin 26 | Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) 27 | rescue SocketError 28 | if tries < max 29 | puts "Retrying DNS warmup: #{host}" 30 | tries += 1 31 | sleep rand 32 | retry 33 | else 34 | puts "DNS warmup failed: #{host}" 35 | host 36 | end 37 | end 38 | 39 | tries 40 | end 41 | end.map(&:value) 42 | end 43 | 44 | retries = results.sum - hosts.size 45 | nopes = results.count { |r| r == max } 46 | 47 | puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] 48 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-traefik-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting Traefik on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "pwabuilder.pwa-studio", 4 | "a-h.templ", 5 | "michelemelluso.code-beautifier", 6 | "golang.go", 7 | "jinliming2.vscode-go-template", 8 | "yzhang.markdown-all-in-one", 9 | "esbenp.prettier-vscode", 10 | "1yib.svelte-bundle", 11 | "pivaszbs.svelte-autoimport", 12 | "svelte.svelte-vscode", 13 | "fivethree.vscode-svelte-snippets", 14 | "bradlc.vscode-tailwindcss" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.customData": [".vscode/tailwind.json"] 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the Go application 2 | FROM golang:1.24.3-bullseye AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # NOTE: Install necessary libraries for SQLite 8 | RUN apt-get update && apt-get install -y \ 9 | gcc \ 10 | musl-dev \ 11 | sqlite3 \ 12 | libsqlite3-dev 13 | 14 | # Copy Go modules and download dependencies 15 | COPY go.mod go.sum ./ 16 | RUN go mod tidy 17 | RUN go mod download 18 | 19 | # Copy the source code 20 | COPY . . 21 | 22 | ENV CGO_ENABLED=1 23 | ENV GOOS=linux 24 | ENV GOARCH=amd64 25 | 26 | # Build the application 27 | RUN GOARCH=amd64 go build -ldflags="-s -w" -gcflags=all=-l -o /app/goship-web ./cmd/web/main.go 28 | RUN GOARCH=amd64 go build -ldflags="-s -w" -gcflags=all=-l -o /app/goship-worker ./cmd/worker/main.go 29 | RUN GOARCH=amd64 go build -ldflags="-s -w" -gcflags=all=-l -o /app/goship-seed ./cmd/seed/main.go 30 | 31 | # Install asynq tools 32 | RUN go install github.com/hibiken/asynq/tools/asynq@latest 33 | 34 | ################################################ 35 | # Stage 2: Create a smaller runtime image 36 | ################################################ 37 | FROM ubuntu:22.04 38 | 39 | # Install necessary packages 40 | RUN apt-get update && apt-get install -y \ 41 | curl 42 | 43 | # Copy the compiled binaries from the builder image 44 | COPY --from=builder /app/goship-web /goship-web 45 | COPY --from=builder /app/goship-worker /goship-worker 46 | COPY --from=builder /app/goship-seed /goship-seed 47 | 48 | # Copy asynq tool 49 | COPY --from=builder /go/bin/asynq /usr/local/bin/ 50 | 51 | # Copy the templates 52 | COPY templates/ /app/templates/ 53 | 54 | # Optional: Bind to a TCP port (document the ports the application listens on) 55 | EXPOSE 8000 56 | EXPOSE 8080 57 | 58 | # Define an entrypoint script 59 | COPY entrypoint.sh /entrypoint.sh 60 | RUN chmod +x /entrypoint.sh 61 | 62 | COPY config/config.yaml . 63 | COPY service-worker.js /service-worker.js 64 | COPY static /static 65 | 66 | # Below is only used if you need to use PWABuilder to make a native Android app 67 | # RUN mkdir pwabuilder-android-wrapper 68 | # COPY pwabuilder-android-wrapper/assetlinks.json pwabuilder-android-wrapper/assetlinks.json 69 | 70 | ENTRYPOINT ["/entrypoint.sh"] 71 | 72 | # Clean up any unnecessary files 73 | RUN apt-get purge -y gcc musl-dev libsqlite3-dev && apt-get autoremove -y && apt-get clean -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mike Stefanello 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | watch-js: make watch-js 2 | watch-go: make watch-go 3 | watch-css: make watch-css 4 | watch-go-worker: make worker 5 | -------------------------------------------------------------------------------- /cmd/seed/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/services" 5 | "github.com/mikestefanello/pagoda/seeder" 6 | ) 7 | 8 | func main() { 9 | c := services.NewContainer() 10 | seeder.SeedUsers(c.Config, c.ORM, true) 11 | c.Shutdown() 12 | } 13 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetConfig(t *testing.T) { 11 | _, err := GetConfig() 12 | require.NoError(t, err) 13 | 14 | cfg, err := GetConfig() 15 | require.NoError(t, err) 16 | assert.Equal(t, EnvLocal, cfg.App.Environment) 17 | } 18 | -------------------------------------------------------------------------------- /config/firewalls/accessories.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This firewall script allows access to the following ports: 3 | # - 6379: Redis 4 | # - 8080: AsyncMon 5 | # - 22: SSH 6 | 7 | YOUR_LAPTOP_IP="192.000.0.000" # SET YOUR LAPTOP IP 8 | 9 | # Reset UFW to default settings 10 | sudo ufw reset 11 | 12 | # Deny all other incoming traffic 13 | sudo ufw default deny incoming 14 | sudo ufw default allow outgoing 15 | 16 | # Allow SSH 17 | sudo ufw allow ssh 18 | 19 | # Allow internal network traffic (assuming your private network range is 10.124.0.0/24) 20 | sudo ufw allow from 10.124.0.0/24 21 | 22 | # Allow Redis port (assuming Redis is exposed to internal network only) 23 | sudo ufw allow from 10.124.0.0/24 to any port 6379 24 | 25 | # Allow AsyncMon port for internal network 26 | sudo ufw allow from 10.124.0.0/24 to any port 8080 27 | 28 | # Allow AsyncMon port for your laptop IP 29 | sudo ufw allow from $YOUR_LAPTOP_IP to any port 8080 30 | 31 | # Enable UFW 32 | sudo ufw enable 33 | 34 | # Show UFW status 35 | sudo ufw status verbose 36 | -------------------------------------------------------------------------------- /config/firewalls/web-app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This firewall script allows access to the following ports: 3 | # - 443: HTTPS 4 | # - 22: SSH 5 | 6 | # Enable UFW and set default rules 7 | sudo ufw default deny incoming 8 | sudo ufw default allow outgoing 9 | sudo ufw enable 10 | 11 | # Allow SSH 12 | sudo ufw allow ssh 13 | 14 | # Fetch Cloudflare IPs and allow them for HTTPS 15 | echo "Fetching Cloudflare IPs..." 16 | curl -s https://www.cloudflare.com/ips-v4/ -o cloudflare-ips-v4.txt 17 | 18 | echo "Configuring UFW to allow Cloudflare IPs for HTTPS..." 19 | while IFS= read -r ip; do 20 | sudo ufw allow from $ip to any port 443 21 | done < cloudflare-ips-v4.txt 22 | 23 | # Clean up 24 | rm cloudflare-ips-v4.txt 25 | 26 | # Enable UFW 27 | sudo ufw enable 28 | 29 | echo "UFW configuration completed successfully." 30 | 31 | ########################### 32 | # Fail-to-ban 33 | ########################### 34 | # Setup fail to ban 35 | apt update && apt upgrade 36 | apt install fail2ban -y 37 | # Verify it's installed 38 | fail2ban-client -h -------------------------------------------------------------------------------- /config/firewalls/worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This firewall script allows access to the following ports: 3 | # - 22: SSH 4 | 5 | # Reset UFW to default settings 6 | sudo ufw reset 7 | 8 | # Deny all other incoming traffic 9 | sudo ufw default deny incoming 10 | sudo ufw default allow outgoing 11 | 12 | # Allow SSH 13 | sudo ufw allow ssh 14 | 15 | # Allow internal network traffic (assuming your private network range is 10.124.0.0/24) 16 | sudo ufw allow from 10.124.0.0/24 17 | 18 | # Enable UFW 19 | sudo ufw enable 20 | 21 | # Show UFW status 22 | sudo ufw status verbose 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | cache: 3 | image: "redis:alpine" 4 | container_name: goship_cache 5 | ports: 6 | - "127.0.0.1:${HOST_CACHE_PORT-6379}:6379" 7 | 8 | # db: 9 | # # PG 16 is currenly not supported by Ent ORM: https://github.com/ent/ent/issues/3750 10 | # image: ankane/pgvector:v0.5.1 11 | # container_name: goship_db 12 | # ports: 13 | # - "127.0.0.1:${HOST_DB_PORT-5432}:5432" 14 | # environment: 15 | # - POSTGRES_USER=admin 16 | # - POSTGRES_PASSWORD=admin 17 | # - POSTGRES_DB=goship_db 18 | 19 | mailpit: 20 | image: axllent/mailpit 21 | container_name: goship_mailpit 22 | restart: always 23 | volumes: 24 | - ./data:/data 25 | ports: 26 | - "${HOST_MAILPIT_HTTP_PORT-8025}:8025" 27 | - "${HOST_MAILPIT_SMTP_PORT-1025}:1025" 28 | environment: 29 | MP_MAX_MESSAGES: 5000 30 | MP_DATA_FILE: /data/mailpit.db 31 | MP_SMTP_AUTH_ACCEPT_ANY: 1 32 | MP_SMTP_AUTH_ALLOW_INSECURE: 1 33 | 34 | -------------------------------------------------------------------------------- /e2e_tests/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | development: { 3 | WEBSITE_URL: "http://localhost:8002", 4 | EMAIL_CAPTURE_PORTAL_URL: "http://localhost:8026", 5 | }, 6 | production: { 7 | WEBSITE_URL: "https://yourproductionurl.com", 8 | }, 9 | // other environments... 10 | }; 11 | 12 | // Default configuration 13 | const defaultConfig = { 14 | WEBSITE_URL: "http://localhost:8002", 15 | }; 16 | 17 | module.exports = { config, defaultConfig }; 18 | -------------------------------------------------------------------------------- /e2e_tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e_tests", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "test:dev": "NODE_ENV=development playwright test", 9 | "test:prod": "NODE_ENV=production playwright test" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@playwright/test": "^1.40.1", 16 | "playwright": "^1.40.1" 17 | }, 18 | "devDependencies": { 19 | "@playwright/test": "^1.44.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /e2e_tests/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig } from "@playwright/test"; 2 | 3 | const config: PlaywrightTestConfig = { 4 | projects: [ 5 | { 6 | name: "Chrome", 7 | use: { browserName: "chromium" }, 8 | }, 9 | ], 10 | workers: 1, // Adjust the number of workers as needed based on your CI environment capabilities 11 | retries: 2, 12 | }; 13 | export default config; 14 | -------------------------------------------------------------------------------- /ent/emojis/emojis.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package emojis 4 | 5 | import ( 6 | "entgo.io/ent/dialect/sql" 7 | ) 8 | 9 | const ( 10 | // Label holds the string label denoting the emojis type in the database. 11 | Label = "emojis" 12 | // FieldID holds the string denoting the id field in the database. 13 | FieldID = "id" 14 | // FieldUnifiedCode holds the string denoting the unified_code field in the database. 15 | FieldUnifiedCode = "unified_code" 16 | // FieldShortcode holds the string denoting the shortcode field in the database. 17 | FieldShortcode = "shortcode" 18 | // Table holds the table name of the emojis in the database. 19 | Table = "emojis" 20 | ) 21 | 22 | // Columns holds all SQL columns for emojis fields. 23 | var Columns = []string{ 24 | FieldID, 25 | FieldUnifiedCode, 26 | FieldShortcode, 27 | } 28 | 29 | // ValidColumn reports if the column name is valid (part of the table columns). 30 | func ValidColumn(column string) bool { 31 | for i := range Columns { 32 | if column == Columns[i] { 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | 39 | var ( 40 | // UnifiedCodeValidator is a validator for the "unified_code" field. It is called by the builders before save. 41 | UnifiedCodeValidator func(string) error 42 | // ShortcodeValidator is a validator for the "shortcode" field. It is called by the builders before save. 43 | ShortcodeValidator func(string) error 44 | ) 45 | 46 | // OrderOption defines the ordering options for the Emojis queries. 47 | type OrderOption func(*sql.Selector) 48 | 49 | // ByID orders the results by the id field. 50 | func ByID(opts ...sql.OrderTermOption) OrderOption { 51 | return sql.OrderByField(FieldID, opts...).ToFunc() 52 | } 53 | 54 | // ByUnifiedCode orders the results by the unified_code field. 55 | func ByUnifiedCode(opts ...sql.OrderTermOption) OrderOption { 56 | return sql.OrderByField(FieldUnifiedCode, opts...).ToFunc() 57 | } 58 | 59 | // ByShortcode orders the results by the shortcode field. 60 | func ByShortcode(opts ...sql.OrderTermOption) OrderOption { 61 | return sql.OrderByField(FieldShortcode, opts...).ToFunc() 62 | } 63 | -------------------------------------------------------------------------------- /ent/enttest/enttest.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package enttest 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/mikestefanello/pagoda/ent" 9 | // required by schema hooks. 10 | _ "github.com/mikestefanello/pagoda/ent/runtime" 11 | 12 | "entgo.io/ent/dialect/sql/schema" 13 | "github.com/mikestefanello/pagoda/ent/migrate" 14 | ) 15 | 16 | type ( 17 | // TestingT is the interface that is shared between 18 | // testing.T and testing.B and used by enttest. 19 | TestingT interface { 20 | FailNow() 21 | Error(...any) 22 | } 23 | 24 | // Option configures client creation. 25 | Option func(*options) 26 | 27 | options struct { 28 | opts []ent.Option 29 | migrateOpts []schema.MigrateOption 30 | } 31 | ) 32 | 33 | // WithOptions forwards options to client creation. 34 | func WithOptions(opts ...ent.Option) Option { 35 | return func(o *options) { 36 | o.opts = append(o.opts, opts...) 37 | } 38 | } 39 | 40 | // WithMigrateOptions forwards options to auto migration. 41 | func WithMigrateOptions(opts ...schema.MigrateOption) Option { 42 | return func(o *options) { 43 | o.migrateOpts = append(o.migrateOpts, opts...) 44 | } 45 | } 46 | 47 | func newOptions(opts []Option) *options { 48 | o := &options{} 49 | for _, opt := range opts { 50 | opt(o) 51 | } 52 | return o 53 | } 54 | 55 | // Open calls ent.Open and auto-run migration. 56 | func Open(t TestingT, driverName, dataSourceName string, opts ...Option) *ent.Client { 57 | o := newOptions(opts) 58 | c, err := ent.Open(driverName, dataSourceName, o.opts...) 59 | if err != nil { 60 | t.Error(err) 61 | t.FailNow() 62 | } 63 | migrateSchema(t, c, o) 64 | return c 65 | } 66 | 67 | // NewClient calls ent.NewClient and auto-run migration. 68 | func NewClient(t TestingT, opts ...Option) *ent.Client { 69 | o := newOptions(opts) 70 | c := ent.NewClient(o.opts...) 71 | migrateSchema(t, c, o) 72 | return c 73 | } 74 | func migrateSchema(t TestingT, c *ent.Client, o *options) { 75 | tables, err := schema.CopyTables(migrate.Tables) 76 | if err != nil { 77 | t.Error(err) 78 | t.FailNow() 79 | } 80 | if err := migrate.Create(context.Background(), c.Schema, tables, o.migrateOpts...); err != nil { 81 | t.Error(err) 82 | t.FailNow() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /ent/generate.go: -------------------------------------------------------------------------------- 1 | package ent 2 | 3 | //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema 4 | -------------------------------------------------------------------------------- /ent/hook/custom_hook.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/mikestefanello/pagoda/ent" 9 | ) 10 | 11 | // EnsureUTCHook creates a hook that ensures specified time fields are in UTC. 12 | func EnsureUTCHook(timeFields ...string) ent.Hook { 13 | return func(next ent.Mutator) ent.Mutator { 14 | return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { 15 | // Iterate over provided time field names and adjust them to UTC 16 | for _, fieldName := range timeFields { 17 | adjustTimeFieldToUTC(m, fieldName) 18 | } 19 | return next.Mutate(ctx, m) 20 | }) 21 | } 22 | } 23 | 24 | // adjustTimeFieldToUTC checks if a time field is set in the mutation and adjusts it to UTC. 25 | func adjustTimeFieldToUTC(m ent.Mutation, fieldName string) { 26 | if value, exists := m.Field(fieldName); exists { 27 | if t, ok := value.(time.Time); ok { 28 | // Ensure the time is in UTC before setting it back 29 | m.SetField(fieldName, t.UTC()) 30 | } else { 31 | log.Printf("Field %s is not a time.Time type", fieldName) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ent/migrate/migrations/20240907154749.sql: -------------------------------------------------------------------------------- 1 | -- Drop "committed_relationship_requests" table 2 | DROP TABLE "committed_relationship_requests"; 3 | -------------------------------------------------------------------------------- /ent/migrate/migrations/20240907155651.sql: -------------------------------------------------------------------------------- 1 | -- Drop "seen_answer_events" table 2 | DROP TABLE "seen_answer_events"; 3 | -- Drop "answers" table 4 | DROP TABLE "answers"; 5 | -- Drop "seen_private_message_events" table 6 | DROP TABLE "seen_private_message_events"; 7 | -- Drop "private_messages" table 8 | DROP TABLE "private_messages"; 9 | -- Drop "question_seen_events" table 10 | DROP TABLE "question_seen_events"; 11 | -- Drop "questions" table 12 | DROP TABLE "questions"; 13 | -------------------------------------------------------------------------------- /ent/migrate/migrations/20240907160557.sql: -------------------------------------------------------------------------------- 1 | -- Modify "profiles" table 2 | ALTER TABLE "profiles" DROP COLUMN "min_interested_age", DROP COLUMN "max_interested_age", DROP COLUMN "latitude", DROP COLUMN "longitude", DROP COLUMN "radius", DROP COLUMN "num_matches"; 3 | -- Drop "profile_disliked_profiles" table 4 | DROP TABLE "profile_disliked_profiles"; 5 | -- Drop "profile_liked_profiles" table 6 | DROP TABLE "profile_liked_profiles"; 7 | -- Drop "profile_matches" table 8 | DROP TABLE "profile_matches"; 9 | -------------------------------------------------------------------------------- /ent/migrate/migrations/20240907214903.sql: -------------------------------------------------------------------------------- 1 | -- Drop index "email_subscription_types_name_key" from table: "email_subscription_types" 2 | DROP INDEX "email_subscription_types_name_key"; 3 | -------------------------------------------------------------------------------- /ent/migrate/migrations/atlas.sum: -------------------------------------------------------------------------------- 1 | h1:b113FOeVMSmG+t7MWRsD6C1J4DqLkSebl5EHt7IS4Jo= 2 | 20240903200944.sql h1:ZyOhdwZuhO86mUHr8QDpkbvyNtXd0rh7lYr75Zt/C00= 3 | 20240907154749.sql h1:N0whZxy+XYfY6Y3aanlxAXttFnN2gXxOvT8Q9q/r5TU= 4 | 20240907155651.sql h1:mmpsRPJjXkScye1TCqsrBoZ9nqvZSud5F26oVsJto+U= 5 | 20240907160557.sql h1:MgvuM9ru8vh2+CgsHBqu4PIGUt1uDilrYW51b5a/txA= 6 | 20240907214903.sql h1:mvzqbHXtNJarHJUBUDW9RvQraVJVXHbX5WhbWAlSh/0= 7 | -------------------------------------------------------------------------------- /ent/predicate/predicate.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package predicate 4 | 5 | import ( 6 | "entgo.io/ent/dialect/sql" 7 | ) 8 | 9 | // EmailSubscription is the predicate function for emailsubscription builders. 10 | type EmailSubscription func(*sql.Selector) 11 | 12 | // EmailSubscriptionType is the predicate function for emailsubscriptiontype builders. 13 | type EmailSubscriptionType func(*sql.Selector) 14 | 15 | // Emojis is the predicate function for emojis builders. 16 | type Emojis func(*sql.Selector) 17 | 18 | // FCMSubscriptions is the predicate function for fcmsubscriptions builders. 19 | type FCMSubscriptions func(*sql.Selector) 20 | 21 | // FileStorage is the predicate function for filestorage builders. 22 | type FileStorage func(*sql.Selector) 23 | 24 | // Image is the predicate function for image builders. 25 | type Image func(*sql.Selector) 26 | 27 | // ImageSize is the predicate function for imagesize builders. 28 | type ImageSize func(*sql.Selector) 29 | 30 | // Invitation is the predicate function for invitation builders. 31 | type Invitation func(*sql.Selector) 32 | 33 | // LastSeenOnline is the predicate function for lastseenonline builders. 34 | type LastSeenOnline func(*sql.Selector) 35 | 36 | // MonthlySubscription is the predicate function for monthlysubscription builders. 37 | type MonthlySubscription func(*sql.Selector) 38 | 39 | // Notification is the predicate function for notification builders. 40 | type Notification func(*sql.Selector) 41 | 42 | // NotificationPermission is the predicate function for notificationpermission builders. 43 | type NotificationPermission func(*sql.Selector) 44 | 45 | // NotificationTime is the predicate function for notificationtime builders. 46 | type NotificationTime func(*sql.Selector) 47 | 48 | // PasswordToken is the predicate function for passwordtoken builders. 49 | type PasswordToken func(*sql.Selector) 50 | 51 | // PhoneVerificationCode is the predicate function for phoneverificationcode builders. 52 | type PhoneVerificationCode func(*sql.Selector) 53 | 54 | // Profile is the predicate function for profile builders. 55 | type Profile func(*sql.Selector) 56 | 57 | // PwaPushSubscription is the predicate function for pwapushsubscription builders. 58 | type PwaPushSubscription func(*sql.Selector) 59 | 60 | // SentEmail is the predicate function for sentemail builders. 61 | type SentEmail func(*sql.Selector) 62 | 63 | // User is the predicate function for user builders. 64 | type User func(*sql.Selector) 65 | -------------------------------------------------------------------------------- /ent/runtime.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | // The schema-stitching logic is generated in github.com/mikestefanello/pagoda/ent/runtime/runtime.go 6 | -------------------------------------------------------------------------------- /ent/schema/emailsubscription.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | ) 8 | 9 | // EmailSubscription holds the schema definition for the EmailSubscription entity. 10 | type EmailSubscription struct { 11 | ent.Schema 12 | } 13 | 14 | func (EmailSubscription) Mixin() []ent.Mixin { 15 | return []ent.Mixin{ 16 | TimeMixin{}, 17 | } 18 | } 19 | 20 | // Fields of the EmailSubscription. 21 | func (EmailSubscription) Fields() []ent.Field { 22 | return []ent.Field{ 23 | field.String("email"). 24 | NotEmpty(). 25 | Unique(), 26 | field.Bool("verified"). 27 | Default(false), 28 | field.String("confirmation_code"). 29 | NotEmpty(). 30 | Unique(), 31 | field.Float("latitude"). 32 | Optional(). 33 | Comment("The latitude of the subscriber's location."), 34 | field.Float("longitude"). 35 | Optional(). 36 | Comment("The longitude of the subscriber's location."), 37 | } 38 | } 39 | 40 | // Edges of the EmailSubscription. 41 | func (EmailSubscription) Edges() []ent.Edge { 42 | return []ent.Edge{ 43 | edge.To("subscriptions", EmailSubscriptionType.Type). 44 | Comment("Subscriptions that this email is subscribed to"), 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ent/schema/emailsubscriptiontype.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "github.com/mikestefanello/pagoda/pkg/domain" 8 | ) 9 | 10 | // TODO: rename to EmailSubscriptionList 11 | // EmailSubscriptionType holds the schema definition for the EmailSubscriptionType entity. 12 | type EmailSubscriptionType struct { 13 | ent.Schema 14 | } 15 | 16 | func (EmailSubscriptionType) Mixin() []ent.Mixin { 17 | return []ent.Mixin{ 18 | TimeMixin{}, 19 | } 20 | } 21 | 22 | // Fields of the EmailSubscriptionType. 23 | func (EmailSubscriptionType) Fields() []ent.Field { 24 | return []ent.Field{ 25 | field.Enum("name"). 26 | Values(domain.EmailSubscriptionLists.Values()...), 27 | field.Bool("active"). 28 | Default(true), 29 | } 30 | } 31 | 32 | // Edges of the EmailSubscriptionType. 33 | func (EmailSubscriptionType) Edges() []ent.Edge { 34 | return []ent.Edge{ 35 | edge.From("subscriber", EmailSubscription.Type). 36 | Ref("subscriptions"). 37 | Comment("Subscriber subscribed to this subscription type."), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ent/schema/emojis.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/field" 6 | ) 7 | 8 | // Emojis holds the schema definition for the Emojis entity. 9 | type Emojis struct { 10 | ent.Schema 11 | } 12 | 13 | // Fields of the Emojis. 14 | func (Emojis) Fields() []ent.Field { 15 | return []ent.Field{ 16 | field.String("unified_code").NotEmpty(), 17 | field.String("shortcode").NotEmpty(), 18 | } 19 | } 20 | 21 | // Edges of the Emojis. 22 | func (Emojis) Edges() []ent.Edge { 23 | return []ent.Edge{ 24 | // edge.To("answer_reactions", AnswerReactions.Type), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ent/schema/fcmsubscriptions.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "entgo.io/ent/schema/index" 8 | ) 9 | 10 | // FCMSubscriptions holds the schema definition for the FCMSubscriptions entity. 11 | type FCMSubscriptions struct { 12 | ent.Schema 13 | } 14 | 15 | func (FCMSubscriptions) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | TimeMixin{}, 18 | } 19 | } 20 | 21 | // Fields of the FCMSubscriptions. 22 | func (FCMSubscriptions) Fields() []ent.Field { 23 | return []ent.Field{ 24 | field.String("token").NotEmpty(), 25 | field.Int("profile_id"), 26 | } 27 | } 28 | 29 | // Edges of the FCMSubscriptions. 30 | func (FCMSubscriptions) Edges() []ent.Edge { 31 | return []ent.Edge{ 32 | edge.From("profile", Profile.Type). 33 | Ref("fcm_push_subscriptions"). 34 | Field("profile_id"). 35 | Required(). 36 | Unique(), 37 | } 38 | } 39 | 40 | // Indexes of the FCMSubscriptions. 41 | func (FCMSubscriptions) Indexes() []ent.Index { 42 | return []ent.Index{ 43 | index.Fields("token", "profile_id").Unique(), 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ent/schema/filestorage.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/field" 6 | "entgo.io/ent/schema/index" 7 | ) 8 | 9 | // FileStorage holds the schema definition for the FileStorage entity. 10 | type FileStorage struct { 11 | ent.Schema 12 | } 13 | 14 | func (FileStorage) Mixin() []ent.Mixin { 15 | return []ent.Mixin{ 16 | TimeMixin{}, 17 | } 18 | } 19 | 20 | // Fields of the FileStorage. 21 | func (FileStorage) Fields() []ent.Field { 22 | return []ent.Field{ 23 | field.String("bucket_name").NotEmpty(), 24 | field.String("object_key").NotEmpty(), 25 | field.String("original_file_name").Optional(), 26 | field.Int64("file_size").Optional(), 27 | field.String("content_type").Optional(), 28 | field.String("file_hash").Optional(), 29 | } 30 | } 31 | 32 | // Edges of the FileStorage. 33 | func (FileStorage) Edges() []ent.Edge { 34 | return nil 35 | } 36 | 37 | // Indexes of the FileStorage. 38 | func (FileStorage) Indexes() []ent.Index { 39 | return []ent.Index{ 40 | index.Fields("bucket_name", "object_key").Unique(), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ent/schema/image.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/dialect/entsql" 6 | "entgo.io/ent/schema/edge" 7 | "entgo.io/ent/schema/field" 8 | "github.com/mikestefanello/pagoda/pkg/domain" 9 | ) 10 | 11 | func (Image) Mixin() []ent.Mixin { 12 | return []ent.Mixin{ 13 | TimeMixin{}, 14 | } 15 | } 16 | 17 | // Image holds the schema definition for the Image entity. 18 | type Image struct { 19 | ent.Schema 20 | } 21 | 22 | // Fields of the Image. 23 | func (Image) Fields() []ent.Field { 24 | return []ent.Field{ 25 | field.Enum("type"). 26 | Values(domain.ImageCategories.Values()...), 27 | } 28 | } 29 | 30 | // Edges of the Image. 31 | func (Image) Edges() []ent.Edge { 32 | return []ent.Edge{ 33 | edge.To("sizes", ImageSize.Type). 34 | Annotations(entsql.OnDelete(entsql.Cascade)), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ent/schema/imagesize.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/dialect/entsql" 6 | "entgo.io/ent/schema/edge" 7 | "entgo.io/ent/schema/field" 8 | "github.com/mikestefanello/pagoda/pkg/domain" 9 | ) 10 | 11 | // ImageSize holds the schema definition for the ImageSize entity. 12 | type ImageSize struct { 13 | ent.Schema 14 | } 15 | 16 | func (ImageSize) Mixin() []ent.Mixin { 17 | return []ent.Mixin{ 18 | TimeMixin{}, 19 | } 20 | } 21 | 22 | // Fields of the ImageSize. 23 | func (ImageSize) Fields() []ent.Field { 24 | return []ent.Field{ 25 | field.Enum("size"). 26 | Values(domain.ImageSizes.Values()...). 27 | Comment("The size of this image instance"), 28 | field.Int("width").Positive(), 29 | field.Int("height").Positive(), 30 | } 31 | } 32 | 33 | // Edges of the ImageSize. 34 | func (ImageSize) Edges() []ent.Edge { 35 | return []ent.Edge{ 36 | edge.To("file", FileStorage.Type). 37 | Unique(). 38 | Required(). 39 | Annotations(entsql.OnDelete(entsql.Cascade)), 40 | // We add the "Required" method to the builder 41 | // to make this edge required on entity creation. 42 | // i.e. Card cannot be created without its owner. 43 | edge.From("image", Image.Type). 44 | Ref("sizes"). 45 | Unique(), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ent/schema/invitation.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | ) 8 | 9 | // Invitation holds the schema definition for the Invitation entity. 10 | type Invitation struct { 11 | ent.Schema 12 | } 13 | 14 | func (Invitation) Mixin() []ent.Mixin { 15 | return []ent.Mixin{ 16 | TimeMixin{}, 17 | } 18 | } 19 | 20 | // Fields of the Invitation. 21 | func (Invitation) Fields() []ent.Field { 22 | return []ent.Field{ 23 | field.String("invitee_name"). 24 | NotEmpty(), 25 | field.String("confirmation_code"). 26 | NotEmpty(). 27 | Unique(), 28 | } 29 | } 30 | 31 | // Edges of the Invitation. 32 | func (Invitation) Edges() []ent.Edge { 33 | return []ent.Edge{ 34 | edge.From("inviter", Profile.Type). 35 | Ref("invitations"). 36 | Unique(). 37 | Required(). 38 | Comment("The profile who created the invitation."), 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ent/schema/lastseenonline.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/schema/edge" 8 | "entgo.io/ent/schema/field" 9 | ) 10 | 11 | // LastSeenOnline holds the schema definition for the LastSeenOnline entity. 12 | type LastSeenOnline struct { 13 | ent.Schema 14 | } 15 | 16 | // Fields of the LastSeenOnline. 17 | func (LastSeenOnline) Fields() []ent.Field { 18 | return []ent.Field{ 19 | field.Time("seen_at"). 20 | Immutable(). 21 | Default(time.Now), 22 | } 23 | } 24 | 25 | // Edges of the LastSeenOnline. 26 | func (LastSeenOnline) Edges() []ent.Edge { 27 | return []ent.Edge{ 28 | edge.From("user", User.Type). 29 | Ref("last_seen_at"). 30 | Unique(). 31 | Required(), 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ent/schema/mixin.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/schema/field" 8 | "entgo.io/ent/schema/mixin" 9 | ) 10 | 11 | // ------------------------------------------------- 12 | // Mixin definition 13 | 14 | // TimeMixin implements the ent.Mixin for sharing 15 | // time fields with package schemas. 16 | type TimeMixin struct { 17 | // We embed the `mixin.Schema` to avoid 18 | // implementing the rest of the methods. 19 | mixin.Schema 20 | } 21 | 22 | func (TimeMixin) Fields() []ent.Field { 23 | return []ent.Field{ 24 | field.Time("created_at"). 25 | Immutable(). 26 | Default(time.Now), 27 | field.Time("updated_at"). 28 | Default(time.Now). 29 | UpdateDefault(time.Now), 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ent/schema/monthlysubscription.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/dialect/entsql" 6 | "entgo.io/ent/schema/edge" 7 | "entgo.io/ent/schema/field" 8 | "entgo.io/ent/schema/index" 9 | "github.com/mikestefanello/pagoda/pkg/domain" 10 | ) 11 | 12 | // MonthlySubscription holds the schema definition for the MonthlySubscription entity. 13 | type MonthlySubscription struct { 14 | ent.Schema 15 | } 16 | 17 | func (MonthlySubscription) Mixin() []ent.Mixin { 18 | return []ent.Mixin{ 19 | TimeMixin{}, 20 | } 21 | } 22 | 23 | // Fields of the MonthlySubscription. 24 | func (MonthlySubscription) Fields() []ent.Field { 25 | return []ent.Field{ 26 | field.Enum("product"). 27 | Default(domain.ProductTypeFree.Value). 28 | Values(domain.ProductTypes.Values()...), 29 | field.Bool("is_active"). 30 | Default(false). 31 | Comment("Whether this subscription is active or not."), 32 | field.Bool("paid"). 33 | Default(false). 34 | Comment("Whether this subscription was paid or not."), 35 | field.Bool("is_trial"). 36 | Default(true). 37 | Comment("Whether this subscription is a trial or not."), 38 | 39 | field.Time("started_at"). 40 | Optional(). 41 | Nillable(). 42 | Comment("When the subscription started being effective."), 43 | field.Time("expired_on"). 44 | Optional(). 45 | Nillable(). 46 | Comment("If the subscription expires, when it does so."), 47 | field.Time("cancelled_at"). 48 | Optional(). 49 | Nillable(). 50 | Comment("Cancelling is effective after current period ends."), 51 | field.Int("paying_profile_id"), 52 | } 53 | } 54 | 55 | // Edges of the MonthlySubscription. 56 | func (MonthlySubscription) Edges() []ent.Edge { 57 | return []ent.Edge{ 58 | edge.To("benefactors", Profile.Type). 59 | Comment("Who is on this subscription."). 60 | Annotations(entsql.OnDelete(entsql.NoAction)), 61 | edge.To("payer", Profile.Type). 62 | Comment("Who is paying for this subscription"). 63 | Unique(). 64 | Field("paying_profile_id"). 65 | Required(). 66 | Annotations(entsql.OnDelete(entsql.NoAction)), 67 | } 68 | } 69 | 70 | // Indexes of the MonthlySubscription. 71 | func (MonthlySubscription) Indexes() []ent.Index { 72 | return []ent.Index{ 73 | // Someone can only have a single active subscription at a time. 74 | index.Fields("paying_profile_id", "is_active").Unique(), 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ent/schema/notification.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "github.com/mikestefanello/pagoda/ent/hook" 8 | "github.com/mikestefanello/pagoda/pkg/domain" 9 | ) 10 | 11 | // Notification holds the schema definition for the Notification entity. 12 | type Notification struct { 13 | ent.Schema 14 | } 15 | 16 | func (Notification) Mixin() []ent.Mixin { 17 | return []ent.Mixin{ 18 | TimeMixin{}, 19 | } 20 | } 21 | 22 | // Fields of the Notification. 23 | func (Notification) Fields() []ent.Field { 24 | return []ent.Field{ 25 | field.Enum("type"). 26 | Values(domain.NotificationTypes.Values()...). 27 | Comment("Type of notification (e.g., message, update)"), 28 | field.String("title"). 29 | Default(""). // TODO: had to set a default because this field was added after model creation and had data in it. 30 | Comment("Title the notification"), 31 | field.String("text"). 32 | Comment("Main content of the notification"), 33 | field.String("link"). 34 | Optional(). 35 | Nillable(). 36 | Comment("Optional URL for the resource related to the notification"), 37 | field.Bool("read"). 38 | Comment("Indicates if the notification has been read"). 39 | Default(false), 40 | field.Time("read_at"). 41 | Comment("Time when the notification was read"). 42 | Optional(). 43 | Nillable(), 44 | field.Int("profile_id_who_caused_notification"). 45 | Optional(). 46 | Nillable(), 47 | field.Int("resource_id_tied_to_notif"). 48 | Optional(). 49 | Nillable(), 50 | field.Bool("read_in_notifications_center"). 51 | Optional(). 52 | Nillable(), 53 | } 54 | } 55 | 56 | // Edges of the Notification. 57 | func (Notification) Edges() []ent.Edge { 58 | return []ent.Edge{ 59 | edge.From("profile", Profile.Type). 60 | Ref("notifications"). 61 | Unique(), 62 | } 63 | } 64 | 65 | func (Notification) Hooks() []ent.Hook { 66 | return []ent.Hook{ 67 | hook.EnsureUTCHook( 68 | "read_at", 69 | ), 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ent/schema/notificationpermission.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "entgo.io/ent/schema/index" 8 | "github.com/mikestefanello/pagoda/pkg/domain" 9 | ) 10 | 11 | // NotificationPermission holds the schema definition for the NotificationPermission entity. 12 | type NotificationPermission struct { 13 | ent.Schema 14 | } 15 | 16 | func (NotificationPermission) Mixin() []ent.Mixin { 17 | return []ent.Mixin{ 18 | TimeMixin{}, 19 | } 20 | } 21 | 22 | // Fields of the NotificationPermission. 23 | func (NotificationPermission) Fields() []ent.Field { 24 | return []ent.Field{ 25 | field.Enum("permission"). 26 | Values(domain.NotificationPermissions.Values()...), 27 | field.Enum("platform"). 28 | Values(domain.NotificationPlatforms.Values()...), 29 | field.Int("profile_id"), 30 | field.String("token"). 31 | Comment("For permissions cancellable through out-of-app-platform, this is like an auth token"), 32 | } 33 | } 34 | 35 | // Edges of the NotificationPermission. 36 | func (NotificationPermission) Edges() []ent.Edge { 37 | return []ent.Edge{ 38 | edge.From("profile", Profile.Type). 39 | Ref("notification_permissions"). 40 | Field("profile_id"). 41 | Required(). 42 | Unique(), 43 | } 44 | } 45 | 46 | // Indexes of the Card. 47 | func (NotificationPermission) Indexes() []ent.Index { 48 | return []ent.Index{ 49 | index.Fields("profile_id", "permission", "platform").Unique(), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ent/schema/notificationtime.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "entgo.io/ent/schema/index" 8 | "github.com/mikestefanello/pagoda/pkg/domain" 9 | ) 10 | 11 | // NotificationTime holds the schema definition for the NotificationTime entity. 12 | type NotificationTime struct { 13 | ent.Schema 14 | } 15 | 16 | func (NotificationTime) Mixin() []ent.Mixin { 17 | return []ent.Mixin{ 18 | TimeMixin{}, 19 | } 20 | } 21 | 22 | // Fields of the NotificationTime. 23 | func (NotificationTime) Fields() []ent.Field { 24 | return []ent.Field{ 25 | field.Enum("type"). 26 | Values(domain.NotificationTypes.Values()...). 27 | Comment("Type of notification (e.g., message, update)"), 28 | field.Int("send_minute"). 29 | Comment("Minutes since UTC midnight (0-1439) when the notification can be sent"). 30 | Min(0). 31 | Max(1439), 32 | field.Int("profile_id"). 33 | Comment("A user should only have 1 entry"). 34 | Unique(), 35 | } 36 | } 37 | 38 | // Edges of the NotificationTime. 39 | func (NotificationTime) Edges() []ent.Edge { 40 | return []ent.Edge{ 41 | edge.From("profile", Profile.Type). 42 | Ref("notification_times"). 43 | Field("profile_id"). 44 | Unique(). 45 | Required(), 46 | } 47 | } 48 | 49 | // Indexes of the Profile. 50 | func (NotificationTime) Indexes() []ent.Index { 51 | return []ent.Index{ 52 | // Someone can only have a single notification time per type 53 | index.Fields("profile_id", "type").Unique(), 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ent/schema/passwordtoken.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/schema/edge" 8 | "entgo.io/ent/schema/field" 9 | ) 10 | 11 | // PasswordToken holds the schema definition for the PasswordToken entity. 12 | type PasswordToken struct { 13 | ent.Schema 14 | } 15 | 16 | // Fields of the PasswordToken. 17 | func (PasswordToken) Fields() []ent.Field { 18 | return []ent.Field{ 19 | field.String("hash"). 20 | Sensitive(). 21 | NotEmpty(), 22 | field.Time("created_at"). 23 | Default(time.Now), 24 | } 25 | } 26 | 27 | // Edges of the PasswordToken. 28 | func (PasswordToken) Edges() []ent.Edge { 29 | return []ent.Edge{ 30 | edge.To("user", User.Type). 31 | Required(). 32 | Unique(), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ent/schema/phoneverificationcode.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "entgo.io/ent/schema/index" 8 | ) 9 | 10 | // PhoneVerificationCode holds the schema definition for the PhoneVerificationCode entity. 11 | type PhoneVerificationCode struct { 12 | ent.Schema 13 | } 14 | 15 | func (PhoneVerificationCode) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | TimeMixin{}, 18 | } 19 | } 20 | 21 | // Fields of the PhoneVerificationCode. 22 | func (PhoneVerificationCode) Fields() []ent.Field { 23 | return []ent.Field{ 24 | field.String("code"). 25 | Comment("The verification code"), 26 | field.Int("profile_id"), 27 | } 28 | } 29 | 30 | // Edges of the PhoneVerificationCode. 31 | func (PhoneVerificationCode) Edges() []ent.Edge { 32 | return []ent.Edge{ 33 | edge.From("profile", Profile.Type). 34 | Ref("phone_verification_code"). 35 | Field("profile_id"). 36 | Unique(). 37 | Required(), 38 | } 39 | } 40 | 41 | // Indexes of the Profile. 42 | func (PhoneVerificationCode) Indexes() []ent.Index { 43 | return []ent.Index{ 44 | // Someone can only have a single active subscription at a time. 45 | index.Fields("code", "profile_id").Unique(), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ent/schema/pwapushsubscription.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "entgo.io/ent/schema/index" 8 | ) 9 | 10 | // PwaPushSubscription holds the schema definition for the PwaPushSubscription entity. 11 | type PwaPushSubscription struct { 12 | ent.Schema 13 | } 14 | 15 | func (PwaPushSubscription) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | TimeMixin{}, 18 | } 19 | } 20 | 21 | // Fields of the PwaPushSubscription. 22 | func (PwaPushSubscription) Fields() []ent.Field { 23 | return []ent.Field{ 24 | field.String("endpoint").NotEmpty(), 25 | field.String("p256dh").NotEmpty(), 26 | field.String("auth").NotEmpty(), 27 | field.Int("profile_id"), 28 | } 29 | } 30 | 31 | // Edges of the PwaPushSubscription. 32 | func (PwaPushSubscription) Edges() []ent.Edge { 33 | return []ent.Edge{ 34 | edge.From("profile", Profile.Type). 35 | Ref("pwa_push_subscriptions"). 36 | Field("profile_id"). 37 | Required(). 38 | Unique(), 39 | } 40 | } 41 | 42 | // Indexes of the Card. 43 | func (PwaPushSubscription) Indexes() []ent.Index { 44 | return []ent.Index{ 45 | index.Fields("profile_id", "endpoint").Unique(), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ent/schema/sentemail.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "github.com/mikestefanello/pagoda/pkg/domain" 8 | ) 9 | 10 | // SentEmail holds the schema definition for the SentEmail entity. 11 | type SentEmail struct { 12 | ent.Schema 13 | } 14 | 15 | func (SentEmail) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | TimeMixin{}, 18 | } 19 | } 20 | 21 | // Fields of the SentEmail. 22 | func (SentEmail) Fields() []ent.Field { 23 | return []ent.Field{ 24 | field.Enum("type"). 25 | Values(domain.NotificationPermissions.Values()...), 26 | } 27 | } 28 | 29 | // Edges of the SentEmail. 30 | func (SentEmail) Edges() []ent.Edge { 31 | return []ent.Edge{ 32 | edge.From("profile", Profile.Type). 33 | Ref("sent_emails"). 34 | Unique(). 35 | Required(), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ent/schema/user.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | ge "github.com/mikestefanello/pagoda/ent" 8 | "github.com/mikestefanello/pagoda/ent/hook" 9 | 10 | "entgo.io/ent" 11 | "entgo.io/ent/dialect/entsql" 12 | "entgo.io/ent/schema/edge" 13 | "entgo.io/ent/schema/field" 14 | ) 15 | 16 | // User holds the schema definition for the User entity. 17 | type User struct { 18 | ent.Schema 19 | } 20 | 21 | func (User) Mixin() []ent.Mixin { 22 | return []ent.Mixin{ 23 | TimeMixin{}, 24 | } 25 | } 26 | 27 | // Fields of the User. 28 | func (User) Fields() []ent.Field { 29 | return []ent.Field{ 30 | field.String("name"). 31 | NotEmpty(), 32 | field.String("email"). 33 | NotEmpty(). 34 | Unique(), 35 | field.String("password"). 36 | Sensitive(). 37 | NotEmpty(), 38 | field.Bool("verified"). 39 | Default(false), 40 | field.Time("last_online"). 41 | Optional(), 42 | } 43 | } 44 | 45 | // Edges of the User. 46 | func (User) Edges() []ent.Edge { 47 | return []ent.Edge{ 48 | edge.From("owner", PasswordToken.Type). 49 | Ref("user"), 50 | 51 | edge.To("profile", Profile.Type). 52 | Unique(). 53 | Annotations(entsql.OnDelete(entsql.Cascade)), 54 | edge.To("last_seen_at", LastSeenOnline.Type). 55 | Annotations(entsql.OnDelete(entsql.Cascade)), 56 | } 57 | } 58 | 59 | // Hooks of the User. 60 | func (User) Hooks() []ent.Hook { 61 | return []ent.Hook{ 62 | hook.On( 63 | func(next ent.Mutator) ent.Mutator { 64 | return hook.UserFunc(func(ctx context.Context, m *ge.UserMutation) (ent.Value, error) { 65 | if v, exists := m.Email(); exists { 66 | m.SetEmail(strings.ToLower(v)) 67 | } 68 | return next.Mutate(ctx, m) 69 | }) 70 | }, 71 | // Limit the hook only for these operations. 72 | ent.OpCreate|ent.OpUpdate|ent.OpUpdateOne, 73 | ), 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Default to running the app if no arguments are given 4 | if [ "$#" -eq 0 ]; then 5 | exec /goship-web 6 | fi 7 | 8 | # Otherwise, run the specified executable 9 | case "$1" in 10 | web) 11 | exec /goship-web 12 | ;; 13 | worker) 14 | /goship-worker & 15 | # asynqmon --port=8080 --redis-addr=localhost:6379 & 16 | wait 17 | ;; 18 | seeder) 19 | exec /goship-seeder 20 | ;; 21 | *) 22 | echo "Unknown command: $1" 23 | exit 1 24 | ;; 25 | esac 26 | 27 | # docker run --rm --name asynqmon -e REDIS_URL="redis:164.92.66.136:6379" -p 8080:8080 hibiken/asynqmon -------------------------------------------------------------------------------- /javascript/svelte/components/SvelteTodoComponent.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
24 |
25 |
Todos
26 |
27 | event.key === 'Enter' && addTodo()} 31 | placeholder="Add new todo" 32 | /> 33 | 39 |
40 | 41 | {#each todos as todo} 42 |
43 |
44 | toggleDone(todo.id)} /> 45 |

46 | {todo.text} 47 |

48 |
49 | 55 |
56 | {/each} 57 |
58 |
59 | 60 | 65 | -------------------------------------------------------------------------------- /javascript/svelte/components/ThemeToggle.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | 45 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/IndividualNotificationPermission.svelte: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/PermissionButton.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/icons/EmailDisabledIcon.svelte: -------------------------------------------------------------------------------- 1 | 3 | 4 | 21 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/icons/EmailEnabledIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/icons/LoadingSpinner.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 25 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/icons/PushDisabledIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/icons/PushEnabledIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/icons/SmsDisabledIcon.svelte: -------------------------------------------------------------------------------- 1 | 3 | 4 | 21 | -------------------------------------------------------------------------------- /javascript/svelte/components/notifications/icons/SmsEnabledIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /javascript/svelte/components/utils.js: -------------------------------------------------------------------------------- 1 | function showSuccessToast(message) { 2 | toast.success(message); 3 | } 4 | -------------------------------------------------------------------------------- /javascript/vanilla/load_scripts_and_styles.js: -------------------------------------------------------------------------------- 1 | export function loadScriptsAndStyles(files) { 2 | // Promise array to track loading status 3 | let promises = []; 4 | 5 | // Helper function to create a promise for each file 6 | function createPromise(file) { 7 | return new Promise((resolve, reject) => { 8 | let element; 9 | 10 | if (file.endsWith(".js")) { 11 | // Create script element for JavaScript file 12 | element = document.createElement("script"); 13 | element.src = file; 14 | element.onload = resolve; 15 | element.onerror = reject; 16 | document.head.appendChild(element); 17 | } else if (file.endsWith(".css")) { 18 | // Create link element for CSS file 19 | element = document.createElement("link"); 20 | element.rel = "stylesheet"; 21 | element.href = file; 22 | element.onload = resolve; 23 | element.onerror = reject; 24 | document.head.appendChild(element); 25 | } else { 26 | reject(new Error(`Unsupported file type: ${file}`)); 27 | } 28 | }); 29 | } 30 | 31 | // Create promises for each file and push them to the promises array 32 | files.forEach((file) => { 33 | promises.push(createPromise(file)); 34 | }); 35 | 36 | // Return a promise that resolves when all promises are resolved 37 | return Promise.all(promises); 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amie", 3 | "version": "1.0.0", 4 | "description": "A collection of tools to make web apps rapidly", 5 | "type": "module", 6 | "scripts": { 7 | "build": "node build.mjs", 8 | "watch": "watchexec --project-origin . -w javascript node build.mjs" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/leomorpho/pagoda.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/leomorpho/pagoda/issues" 19 | }, 20 | "homepage": "https://github.com/leomorpho/pagoda#readme", 21 | "devDependencies": { 22 | "@faker-js/faker": "^8.4.1", 23 | "@types/node": "^20.12.7", 24 | "daisyui": "^4.6.2", 25 | "esbuild": "^0.20.0", 26 | "esbuild-svelte": "^0.8.0", 27 | "flowbite": "^2.3.0", 28 | "flowbite-svelte": "^0.44.24", 29 | "svelte": "^4.2.10", 30 | "svelte-preprocess": "^5.1.3", 31 | "tailwindcss": "^3.4.1", 32 | "typescript": "^5.4.2" 33 | }, 34 | "dependencies": { 35 | "@deck.gl/mapbox": "^8.9.35", 36 | "@khmyznikov/pwa-install": "^0.3.7", 37 | "@tiptap/extension-floating-menu": "^2.3.0", 38 | "@tiptap/extension-placeholder": "^2.3.0", 39 | "@tiptap/starter-kit": "^2.3.0", 40 | "@use-gesture/vanilla": "^10.3.0", 41 | "@virgilsecurity/e3kit-browser": "^3.0.5", 42 | "clsx": "^2.1.0", 43 | "svelte-maplibre": "^0.8.2", 44 | "svelte-multiselect": "^10.2.0", 45 | "svelte-tel-input": "^3.3.9", 46 | "svelte-tiptap": "^1.1.2", 47 | "uppload": "^2.7.2", 48 | "wc-toast": "^1.3.1", 49 | "workbox-precaching": "^7.0.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pgvector-image/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the pgvector base image 2 | FROM ankane/pgvector:v0.5.1 3 | 4 | # Set environment variables for PostgreSQL 5 | ENV POSTGRES_USER=postgres 6 | ENV POSTGRES_DB=postgres 7 | ENV POSTGRES_HOST_AUTH_METHOD=trust 8 | 9 | # Add initialization script 10 | COPY init_pgvector.sh /docker-entrypoint-initdb.d/ 11 | 12 | # Ensure the script has the correct permissions 13 | RUN chmod +x /docker-entrypoint-initdb.d/init_pgvector.sh -------------------------------------------------------------------------------- /pgvector-image/init_pgvector.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Function to check if PostgreSQL is ready 5 | check_postgres() { 6 | pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" 7 | } 8 | 9 | # Wait for PostgreSQL to be ready 10 | echo "Waiting for PostgreSQL to start..." 11 | until check_postgres; do 12 | echo "PostgreSQL is unavailable - sleeping" 13 | sleep 2 14 | done 15 | 16 | # Create the extension 17 | echo "PostgreSQL is up - creating extension" 18 | psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS vector;" 19 | -------------------------------------------------------------------------------- /pkg/context/context.go: -------------------------------------------------------------------------------- 1 | package context // TODO: rename this package, it conflicts with the std lib 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | const ( 9 | // AuthenticatedUserKey is the key value used to store the authenticated user in context 10 | AuthenticatedUserKey = "auth_user" 11 | AuthenticatedUserProfilePicURL = "profile_pic_url" 12 | ProfileFullyOnboarded = "profile_fully_onboarded" 13 | ActiveProductPlan = "product_plan" 14 | 15 | // UserKey is the key value used to store a user in context 16 | UserKey = "user" 17 | 18 | // FormKey is the key value used to store a form in context 19 | FormKey = "form" 20 | 21 | // PasswordTokenKey is the key value used to store a password token in context 22 | PasswordTokenKey = "password_token" 23 | 24 | IsFromIOSApp = "is_from_ios_app" 25 | ) 26 | 27 | // IsCanceledError determines if an error is due to a context cancelation 28 | func IsCanceledError(err error) bool { 29 | return errors.Is(err, context.Canceled) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/context/context_test.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestIsCanceled(t *testing.T) { 13 | ctx, cancel := context.WithCancel(context.Background()) 14 | assert.False(t, IsCanceledError(ctx.Err())) 15 | cancel() 16 | assert.True(t, IsCanceledError(ctx.Err())) 17 | 18 | ctx, cancel = context.WithTimeout(context.Background(), time.Microsecond*5) 19 | <-ctx.Done() 20 | cancel() 21 | assert.False(t, IsCanceledError(ctx.Err())) 22 | 23 | assert.False(t, IsCanceledError(errors.New("test error"))) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/controller/form_test.go: -------------------------------------------------------------------------------- 1 | package controller_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mikestefanello/pagoda/pkg/controller" 7 | "github.com/mikestefanello/pagoda/pkg/tests" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestFormSubmission(t *testing.T) { 14 | type formTest struct { 15 | Name string `validate:"required"` 16 | Email string `validate:"required,email"` 17 | Submission controller.FormSubmission 18 | } 19 | 20 | ctx, _ := tests.NewContext(c.Web, "/") 21 | form := formTest{ 22 | Name: "", 23 | Email: "a@a.com", 24 | } 25 | err := form.Submission.Process(ctx, form) 26 | assert.NoError(t, err) 27 | 28 | assert.True(t, form.Submission.HasErrors()) 29 | assert.True(t, form.Submission.FieldHasErrors("Name")) 30 | assert.False(t, form.Submission.FieldHasErrors("Email")) 31 | require.Len(t, form.Submission.GetFieldErrors("Name"), 1) 32 | assert.Len(t, form.Submission.GetFieldErrors("Email"), 0) 33 | assert.Equal(t, "This field is required.", form.Submission.GetFieldErrors("Name")[0]) 34 | assert.Equal(t, "is-danger", form.Submission.GetFieldStatusClass("Name")) 35 | assert.Equal(t, "is-success", form.Submission.GetFieldStatusClass("Email")) 36 | assert.False(t, form.Submission.IsDone()) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/controller/page_test.go: -------------------------------------------------------------------------------- 1 | package controller_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | echomw "github.com/labstack/echo/v4/middleware" 8 | "github.com/mikestefanello/pagoda/pkg/context" 9 | "github.com/mikestefanello/pagoda/pkg/controller" 10 | "github.com/mikestefanello/pagoda/pkg/repos/msg" 11 | "github.com/mikestefanello/pagoda/pkg/tests" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestNewPage(t *testing.T) { 17 | ctx, _ := tests.NewContext(c.Web, "/") 18 | p := controller.NewPage(ctx) 19 | assert.Same(t, ctx, p.Context) 20 | assert.NotNil(t, p.ToURL) 21 | assert.Equal(t, "/", p.Path) 22 | assert.Equal(t, "/", p.URL) 23 | assert.Equal(t, http.StatusOK, p.StatusCode) 24 | assert.Equal(t, controller.NewPager(ctx, controller.DefaultItemsPerPage), p.Pager) 25 | assert.Empty(t, p.Headers) 26 | assert.True(t, p.IsHome) 27 | assert.False(t, p.IsAuth) 28 | assert.Empty(t, p.CSRF) 29 | assert.Empty(t, p.RequestID) 30 | assert.False(t, p.Cache.Enabled) 31 | 32 | ctx, _ = tests.NewContext(c.Web, "/abc?def=123") 33 | usr, err := tests.CreateRandomUser(c.ORM) 34 | require.NoError(t, err) 35 | ctx.Set(context.AuthenticatedUserKey, usr) 36 | ctx.Set(echomw.DefaultCSRFConfig.ContextKey, "csrf") 37 | p = controller.NewPage(ctx) 38 | assert.Equal(t, "/abc", p.Path) 39 | assert.Equal(t, "/abc?def=123", p.URL) 40 | assert.False(t, p.IsHome) 41 | assert.True(t, p.IsAuth) 42 | assert.Equal(t, usr, p.AuthUser) 43 | assert.Equal(t, "csrf", p.CSRF) 44 | } 45 | 46 | func TestPage_GetMessages(t *testing.T) { 47 | ctx, _ := tests.NewContext(c.Web, "/") 48 | tests.InitSession(ctx) 49 | p := controller.NewPage(ctx) 50 | 51 | // Set messages 52 | msgTests := make(map[msg.Type][]string) 53 | msgTests[msg.TypeWarning] = []string{ 54 | "abc", 55 | "def", 56 | } 57 | msgTests[msg.TypeInfo] = []string{ 58 | "123", 59 | "456", 60 | } 61 | for typ, values := range msgTests { 62 | for _, value := range values { 63 | msg.Set(ctx, typ, value) 64 | } 65 | } 66 | 67 | // Get the messages 68 | for typ, values := range msgTests { 69 | msgs := p.GetMessages(typ) 70 | 71 | for i, message := range msgs { 72 | assert.Equal(t, values[i], string(message)) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /pkg/controller/pager_test.go: -------------------------------------------------------------------------------- 1 | package controller_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/mikestefanello/pagoda/pkg/controller" 8 | "github.com/mikestefanello/pagoda/pkg/tests" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNewPager(t *testing.T) { 14 | ctx, _ := tests.NewContext(c.Web, "/") 15 | pgr := controller.NewPager(ctx, 10) 16 | assert.Equal(t, 10, pgr.ItemsPerPage) 17 | assert.Equal(t, 1, pgr.Page) 18 | assert.Equal(t, 0, pgr.Items) 19 | assert.Equal(t, 0, pgr.Pages) 20 | 21 | ctx, _ = tests.NewContext(c.Web, fmt.Sprintf("/abc?%s=%d", controller.PageQueryKey, 2)) 22 | pgr = controller.NewPager(ctx, 10) 23 | assert.Equal(t, 2, pgr.Page) 24 | 25 | ctx, _ = tests.NewContext(c.Web, fmt.Sprintf("/abc?%s=%d", controller.PageQueryKey, -2)) 26 | pgr = controller.NewPager(ctx, 10) 27 | assert.Equal(t, 1, pgr.Page) 28 | } 29 | 30 | func TestPager_SetItems(t *testing.T) { 31 | ctx, _ := tests.NewContext(c.Web, "/") 32 | pgr := controller.NewPager(ctx, 20) 33 | pgr.SetItems(100) 34 | assert.Equal(t, 100, pgr.Items) 35 | assert.Equal(t, 5, pgr.Pages) 36 | } 37 | 38 | func TestPager_IsBeginning(t *testing.T) { 39 | ctx, _ := tests.NewContext(c.Web, "/") 40 | pgr := controller.NewPager(ctx, 20) 41 | pgr.Pages = 10 42 | assert.True(t, pgr.IsBeginning()) 43 | pgr.Page = 2 44 | assert.False(t, pgr.IsBeginning()) 45 | pgr.Page = 1 46 | assert.True(t, pgr.IsBeginning()) 47 | } 48 | 49 | func TestPager_IsEnd(t *testing.T) { 50 | ctx, _ := tests.NewContext(c.Web, "/") 51 | pgr := controller.NewPager(ctx, 20) 52 | pgr.Pages = 10 53 | assert.False(t, pgr.IsEnd()) 54 | pgr.Page = 10 55 | assert.True(t, pgr.IsEnd()) 56 | pgr.Page = 1 57 | assert.False(t, pgr.IsEnd()) 58 | } 59 | 60 | func TestPager_GetOffset(t *testing.T) { 61 | ctx, _ := tests.NewContext(c.Web, "/") 62 | pgr := controller.NewPager(ctx, 20) 63 | assert.Equal(t, 0, pgr.GetOffset()) 64 | pgr.Page = 2 65 | assert.Equal(t, 20, pgr.GetOffset()) 66 | pgr.Page = 3 67 | assert.Equal(t, 40, pgr.GetOffset()) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/domain/constants.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | var DefaultBirthdate = time.Date(1898, time.January, 6, 0, 0, 0, 0, time.UTC) 6 | 7 | // ImageSizeEnumToSizeMap maps an image size name to its actual size 8 | var ImageSizeEnumToSizeMap = map[ImageSize]int{ 9 | ImageSizeThumbnail: 150, // max 150 pixels wide or tall 10 | ImageSizePreview: 800, // max 800 pixels wide or tall 11 | ImageSizeFull: 1600, // max 1600 pixels wide or tall 12 | } 13 | 14 | const ( 15 | DefaultBio = "Hello, there! 🌟" 16 | FreePlanNumAnswersPerDay = 1 17 | PermissionNotificationType = "permission" 18 | ) 19 | -------------------------------------------------------------------------------- /pkg/domain/maps.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | // Initialize the map of NotificationPermissionType to NotificationPermission 4 | var NotificationPermissionMap = map[NotificationPermissionType]NotificationPermission{ 5 | NotificationPermissionDailyReminder: { 6 | Title: "Daily conversation", 7 | Subtitle: "A reminder to not miss today's question, sent at most once a day.", 8 | Permission: NotificationPermissionDailyReminder.Value, 9 | }, 10 | NotificationPermissionNewFriendActivity: { 11 | Title: "Partner activity", 12 | Subtitle: "Answers you missed, sent at most once a day.", 13 | Permission: NotificationPermissionNewFriendActivity.Value, 14 | }, 15 | } 16 | 17 | var NotificationCenterButtonText = map[NotificationType]string{ 18 | NotificationTypeConnectionEngagedWithQuestion: "Answer", 19 | } 20 | 21 | // DeleteOnceReadNotificationTypesMap is a map of notification types th;oiSJDfiujladijrgoizdikrjgat can be deleted once seen. 22 | // Note that the boolean doesn't matter, this is just a lazy way of creating a set in Go. 23 | var DeleteOnceReadNotificationTypesMap = map[NotificationType]bool{ 24 | NotificationTypeDailyConversationReminder: true, 25 | } 26 | -------------------------------------------------------------------------------- /pkg/funcmap/funcmap.go: -------------------------------------------------------------------------------- 1 | package funcmap 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/mikestefanello/pagoda/config" 10 | 11 | "github.com/Masterminds/sprig" 12 | "github.com/labstack/gommon/random" 13 | ) 14 | 15 | var ( 16 | // CacheBuster stores a random string used as a cache buster for static files. 17 | CacheBuster = random.String(10) 18 | ) 19 | 20 | // GetFuncMap provides a template function map 21 | func GetFuncMap() template.FuncMap { 22 | // See http://masterminds.github.io/sprig/ for available funcs 23 | funcMap := sprig.FuncMap() 24 | 25 | // Provide a list of custom functions 26 | // Expand this as you add more functions to this package 27 | // Avoid using a name already in use by sprig 28 | f := template.FuncMap{ 29 | "hasField": HasField, 30 | "file": File, 31 | "link": Link, 32 | } 33 | 34 | for k, v := range f { 35 | funcMap[k] = v 36 | } 37 | 38 | return funcMap 39 | } 40 | 41 | // HasField checks if an interface contains a given field 42 | func HasField(v any, name string) bool { 43 | rv := reflect.ValueOf(v) 44 | if rv.Kind() == reflect.Ptr { 45 | rv = rv.Elem() 46 | } 47 | if rv.Kind() != reflect.Struct { 48 | return false 49 | } 50 | return rv.FieldByName(name).IsValid() 51 | } 52 | 53 | // File appends a cache buster to a given filepath so it can remain cached until the app is restarted 54 | func File(filepath string) string { 55 | return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, CacheBuster) 56 | } 57 | 58 | // Link outputs HTML for a link element, providing the ability to dynamically set the active class 59 | func Link(url, text, currentPath string, classes ...string) template.HTML { 60 | if currentPath == url { 61 | classes = append(classes, "is-active") 62 | } 63 | 64 | html := fmt.Sprintf(`%s`, strings.Join(classes, " "), url, text) 65 | return template.HTML(html) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/funcmap/funcmap_test.go: -------------------------------------------------------------------------------- 1 | package funcmap 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/mikestefanello/pagoda/config" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestHasField(t *testing.T) { 13 | type example struct { 14 | name string 15 | } 16 | var e example 17 | assert.True(t, HasField(e, "name")) 18 | assert.False(t, HasField(e, "abcd")) 19 | } 20 | 21 | func TestLink(t *testing.T) { 22 | link := string(Link("/abc", "Text", "/abc")) 23 | expected := `Text` 24 | assert.Equal(t, expected, link) 25 | 26 | link = string(Link("/abc", "Text", "/abc", "first", "second")) 27 | expected = `Text` 28 | assert.Equal(t, expected, link) 29 | 30 | link = string(Link("/abc", "Text", "/def")) 31 | expected = `Text` 32 | assert.Equal(t, expected, link) 33 | } 34 | 35 | func TestFile(t *testing.T) { 36 | file := File("test.png") 37 | expected := fmt.Sprintf("/%s/test.png?v=%s", config.StaticPrefix, CacheBuster) 38 | assert.Equal(t, expected, file) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/htmx/htmx_test.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/mikestefanello/pagoda/pkg/tests" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | func TestSetRequest(t *testing.T) { 15 | ctx, _ := tests.NewContext(echo.New(), "/") 16 | ctx.Request().Header.Set(HeaderRequest, "true") 17 | ctx.Request().Header.Set(HeaderBoosted, "true") 18 | ctx.Request().Header.Set(HeaderTrigger, "a") 19 | ctx.Request().Header.Set(HeaderTriggerName, "b") 20 | ctx.Request().Header.Set(HeaderTarget, "c") 21 | ctx.Request().Header.Set(HeaderPrompt, "d") 22 | 23 | r := GetRequest(ctx) 24 | assert.Equal(t, true, r.Enabled) 25 | assert.Equal(t, true, r.Boosted) 26 | assert.Equal(t, "a", r.Trigger) 27 | assert.Equal(t, "b", r.TriggerName) 28 | assert.Equal(t, "c", r.Target) 29 | assert.Equal(t, "d", r.Prompt) 30 | } 31 | 32 | func TestResponse_Apply(t *testing.T) { 33 | ctx, _ := tests.NewContext(echo.New(), "/") 34 | r := Response{ 35 | Push: "a", 36 | Redirect: "b", 37 | Refresh: true, 38 | Trigger: "c", 39 | TriggerAfterSwap: "d", 40 | TriggerAfterSettle: "e", 41 | NoContent: true, 42 | } 43 | r.Apply(ctx) 44 | 45 | assert.Equal(t, "a", ctx.Response().Header().Get(HeaderPush)) 46 | assert.Equal(t, "b", ctx.Response().Header().Get(HeaderRedirect)) 47 | assert.Equal(t, "true", ctx.Response().Header().Get(HeaderRefresh)) 48 | assert.Equal(t, "c", ctx.Response().Header().Get(HeaderTrigger)) 49 | assert.Equal(t, "d", ctx.Response().Header().Get(HeaderTriggerAfterSwap)) 50 | assert.Equal(t, "e", ctx.Response().Header().Get(HeaderTriggerAfterSettle)) 51 | assert.Equal(t, http.StatusNoContent, ctx.Response().Status) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/middleware/cache_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/mikestefanello/pagoda/pkg/tests" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestServeCachedPage(t *testing.T) { 17 | // Cache a page 18 | cp := CachedPage{ 19 | URL: "/cache", 20 | HTML: []byte("html"), 21 | Headers: make(map[string]string), 22 | StatusCode: http.StatusCreated, 23 | } 24 | cp.Headers["a"] = "b" 25 | cp.Headers["c"] = "d" 26 | 27 | err := c.Cache. 28 | Set(). 29 | Group(CachedPageGroup). 30 | Key(cp.URL). 31 | Data(cp). 32 | Save(context.Background()) 33 | require.NoError(t, err) 34 | 35 | // Request the URL of the cached page 36 | ctx, rec := tests.NewContext(c.Web, cp.URL) 37 | err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.Cache)) 38 | assert.NoError(t, err) 39 | assert.Equal(t, cp.StatusCode, ctx.Response().Status) 40 | assert.Equal(t, cp.Headers["a"], ctx.Response().Header().Get("a")) 41 | assert.Equal(t, cp.Headers["c"], ctx.Response().Header().Get("c")) 42 | assert.Equal(t, cp.HTML, rec.Body.Bytes()) 43 | 44 | // Login and try again 45 | tests.InitSession(ctx) 46 | err = c.Auth.Login(ctx, usr.ID) 47 | require.NoError(t, err) 48 | _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth, nil, nil)) 49 | err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.Cache)) 50 | assert.Nil(t, err) 51 | } 52 | 53 | func TestCacheControl(t *testing.T) { 54 | ctx, _ := tests.NewContext(c.Web, "/") 55 | _ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5)) 56 | assert.Equal(t, "public, max-age=5", ctx.Response().Header().Get("Cache-Control")) 57 | _ = tests.ExecuteMiddleware(ctx, CacheControl(0)) 58 | assert.Equal(t, "no-cache, no-store", ctx.Response().Header().Get("Cache-Control")) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/middleware/device.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/context" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | // LoadAuthenticatedUser loads the authenticated user, if one, and stores in context 10 | func SetDeviceTypeToServe() echo.MiddlewareFunc { 11 | return func(next echo.HandlerFunc) echo.HandlerFunc { 12 | return func(c echo.Context) error { 13 | 14 | // Check for `app-platform` cookie 15 | appPlatformCookie, err := c.Cookie("app-platform") 16 | var isiOSApp bool 17 | if err == nil && appPlatformCookie != nil { 18 | isiOSApp = appPlatformCookie.Value == "iOS App Store" 19 | } 20 | c.Set(context.IsFromIOSApp, isiOSApp) 21 | 22 | return next(c) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/middleware/entity.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/mikestefanello/pagoda/ent" 9 | "github.com/mikestefanello/pagoda/ent/user" 10 | "github.com/mikestefanello/pagoda/pkg/context" 11 | 12 | "github.com/labstack/echo/v4" 13 | ) 14 | 15 | // LoadUser loads the user based on the ID provided as a path parameter 16 | func LoadUser(orm *ent.Client) echo.MiddlewareFunc { 17 | return func(next echo.HandlerFunc) echo.HandlerFunc { 18 | return func(c echo.Context) error { 19 | userID, err := strconv.Atoi(c.Param("user")) 20 | if err != nil { 21 | return echo.NewHTTPError(http.StatusNotFound) 22 | } 23 | 24 | u, err := orm.User. 25 | Query(). 26 | Where(user.ID(userID)). 27 | Only(c.Request().Context()) 28 | 29 | switch err.(type) { 30 | case nil: 31 | c.Set(context.UserKey, u) 32 | return next(c) 33 | case *ent.NotFoundError: 34 | return echo.NewHTTPError(http.StatusNotFound) 35 | default: 36 | return echo.NewHTTPError( 37 | http.StatusInternalServerError, 38 | fmt.Sprintf("error querying user: %v", err), 39 | ) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/middleware/entity_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/mikestefanello/pagoda/ent" 8 | "github.com/mikestefanello/pagoda/pkg/context" 9 | "github.com/mikestefanello/pagoda/pkg/tests" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestLoadUser(t *testing.T) { 16 | ctx, _ := tests.NewContext(c.Web, "/") 17 | ctx.SetParamNames("user") 18 | ctx.SetParamValues(fmt.Sprintf("%d", usr.ID)) 19 | _ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM)) 20 | ctxUsr, ok := ctx.Get(context.UserKey).(*ent.User) 21 | require.True(t, ok) 22 | assert.Equal(t, usr.ID, ctxUsr.ID) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/middleware/lastseenonline.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/labstack/echo/v4" 8 | "github.com/mikestefanello/pagoda/ent" 9 | "github.com/mikestefanello/pagoda/pkg/services" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | // LoadAuthenticatedUser loads the authenticated user, if one, and stores in context 14 | func SetLastSeenOnline(authClient *services.AuthClient) echo.MiddlewareFunc { 15 | return func(next echo.HandlerFunc) echo.HandlerFunc { 16 | return func(c echo.Context) error { 17 | u, err := authClient.GetAuthenticatedUser(c) 18 | switch err.(type) { 19 | case *ent.NotFoundError: 20 | c.Logger().Warn("auth user not found") 21 | case services.NotAuthenticatedError: 22 | case nil: 23 | err = authClient.SetLastOnlineTimestamp(c, u.ID) 24 | if err != nil { 25 | log.Error().Err(err).Msg("failed to set last seen online") 26 | } 27 | c.Logger().Infof("last seen timestamp set for user: %d", u.ID) 28 | default: 29 | return echo.NewHTTPError( 30 | http.StatusInternalServerError, 31 | fmt.Sprintf("error querying for authenticated user: %v", err), 32 | ) 33 | } 34 | 35 | return next(c) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/middleware/log.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | // LogRequestID includes the request ID in all logs for the given request 10 | // This requires that middleware that includes the request ID first execute 11 | func LogRequestID() echo.MiddlewareFunc { 12 | return func(next echo.HandlerFunc) echo.HandlerFunc { 13 | return func(c echo.Context) error { 14 | rID := c.Response().Header().Get(echo.HeaderXRequestID) 15 | format := `{"time":"${time_rfc3339_nano}","id":"%s","level":"${level}","prefix":"${prefix}","file":"${short_file}","line":"${line}"}` 16 | c.Logger().SetHeader(fmt.Sprintf(format, rID)) 17 | return next(c) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/middleware/log_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | // import ( 4 | // "bytes" 5 | // "fmt" 6 | // "testing" 7 | 8 | // "github.com/mikestefanello/pagoda/pkg/tests" 9 | 10 | // "github.com/labstack/echo/v4" 11 | 12 | // "github.com/stretchr/testify/assert" 13 | 14 | // echomw "github.com/labstack/echo/v4/middleware" 15 | // ) 16 | 17 | // TODO: unskip and fix this test 18 | // func TestLogRequestID(t *testing.T) { 19 | // ctx, _ := tests.NewContext(c.Web, "/") 20 | // _ = tests.ExecuteMiddleware(ctx, echomw.RequestID()) 21 | // _ = tests.ExecuteMiddleware(ctx, LogRequestID()) 22 | 23 | // var buf bytes.Buffer 24 | // ctx.Logger().SetOutput(&buf) 25 | // ctx.Logger().Info("test") 26 | // rID := ctx.Response().Header().Get(echo.HeaderXRequestID) 27 | // assert.Contains(t, buf.String(), fmt.Sprintf(`id":"%s"`, rID)) 28 | // } 29 | -------------------------------------------------------------------------------- /pkg/middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/mikestefanello/pagoda/config" 8 | "github.com/mikestefanello/pagoda/ent" 9 | "github.com/mikestefanello/pagoda/pkg/services" 10 | "github.com/mikestefanello/pagoda/pkg/tests" 11 | ) 12 | 13 | var ( 14 | c *services.Container 15 | usr *ent.User 16 | ) 17 | 18 | func TestMain(m *testing.M) { 19 | // Set the environment to test 20 | config.SwitchEnvironment(config.EnvTest) 21 | 22 | // Create a new container 23 | c = services.NewContainer() 24 | 25 | // Create a user 26 | var err error 27 | if usr, err = tests.CreateRandomUser(c.ORM); err != nil { 28 | panic(err) 29 | } 30 | 31 | // Run tests 32 | exitVal := m.Run() 33 | 34 | // Shutdown the container 35 | if err = c.Shutdown(); err != nil { 36 | panic(err) 37 | } 38 | 39 | os.Exit(exitVal) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/middleware/onboarding.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/mikestefanello/pagoda/pkg/context" 8 | "github.com/mikestefanello/pagoda/pkg/routing/routenames" 9 | ) 10 | 11 | func RedirectToOnboardingIfNotComplete() echo.MiddlewareFunc { 12 | return func(next echo.HandlerFunc) echo.HandlerFunc { 13 | return func(c echo.Context) error { 14 | if c.Get(context.ProfileFullyOnboarded) == nil { 15 | return echo.NewHTTPError(http.StatusInternalServerError) 16 | } 17 | isFullyOnboarded := c.Get(context.ProfileFullyOnboarded).(bool) 18 | if !isFullyOnboarded { 19 | url := c.Echo().Reverse(routenames.RouteNamePreferences) 20 | return c.Redirect(303, url) 21 | } 22 | return next(c) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/middleware/sentry.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/getsentry/sentry-go" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | func FilterSentryErrors(next echo.HandlerFunc) echo.HandlerFunc { 11 | return func(c echo.Context) error { 12 | err := next(c) 13 | if err != nil { 14 | // Log error without forwarding to Sentry if it's a 404 15 | httpErr, ok := err.(*echo.HTTPError) 16 | if ok && httpErr.Code == http.StatusNotFound { 17 | // Log or handle the error as you wish, but don't forward to Sentry 18 | c.Logger().Error(err) 19 | return err 20 | } 21 | // Forward other errors to Sentry 22 | sentry.CaptureException(err) 23 | } 24 | return err 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/repos/mailer/resend.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/resend/resend-go/v2" 8 | ) 9 | 10 | type ResendMailClient struct { 11 | client *resend.Client 12 | } 13 | 14 | func NewResendMailClient(apiKey string) *ResendMailClient { 15 | client := resend.NewClient(apiKey) 16 | return &ResendMailClient{client: client} 17 | } 18 | 19 | func (r *ResendMailClient) Send(email *mail) error { 20 | params := &resend.SendEmailRequest{ 21 | To: []string{email.to}, 22 | From: email.from, 23 | Subject: email.subject, 24 | } 25 | 26 | if email.component != nil { 27 | params.Html = email.body 28 | } else { 29 | params.Text = email.body 30 | } 31 | 32 | ctx := context.TODO() 33 | _, err := r.client.Emails.SendWithContext(ctx, params) 34 | if err != nil { 35 | return fmt.Errorf("resend mail client failed to send email: %w", err) 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/repos/mailer/smtp.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "fmt" 5 | "net/smtp" 6 | ) 7 | 8 | type SMTPMailClient struct { 9 | Port int 10 | Host string 11 | } 12 | 13 | func NewSMTPMailClient(host string, port int) *SMTPMailClient { 14 | return &SMTPMailClient{ 15 | Port: port, 16 | Host: host, 17 | } 18 | } 19 | 20 | // Send sends an email using SMTP 21 | func (c *SMTPMailClient) Send(email *mail) error { 22 | // Define email headers and body 23 | msg := fmt.Sprintf("From: %s\nTo: %s\nSubject: %s\nMIME-Version: 1.0\nContent-Type: text/html; charset=\"utf-8\"\n\n%s", email.from, email.to, email.subject, email.body) 24 | 25 | // SMTP server configuration 26 | smtpHost := c.Host 27 | smtpPort := c.Port 28 | smtpAddr := fmt.Sprintf("%s:%d", smtpHost, smtpPort) 29 | 30 | // Authentication - Mailpit does not require authentication, but you could add it here if needed 31 | auth := smtp.PlainAuth("", "", "", smtpHost) 32 | 33 | // Sending email 34 | err := smtp.SendMail(smtpAddr, auth, email.from, []string{email.to}, []byte(msg)) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/repos/msg/msg_test.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mikestefanello/pagoda/pkg/tests" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | func TestMsg(t *testing.T) { 15 | e := echo.New() 16 | ctx, _ := tests.NewContext(e, "/") 17 | tests.InitSession(ctx) 18 | 19 | assertMsg := func(typ Type, message string) { 20 | ret := Get(ctx, typ) 21 | require.Len(t, ret, 1) 22 | assert.Equal(t, message, ret[0]) 23 | ret = Get(ctx, typ) 24 | require.Len(t, ret, 0) 25 | } 26 | 27 | text := "aaa" 28 | Success(ctx, text) 29 | assertMsg(TypeSuccess, text) 30 | 31 | text = "bbb" 32 | Info(ctx, text) 33 | assertMsg(TypeInfo, text) 34 | 35 | text = "ccc" 36 | Danger(ctx, text) 37 | assertMsg(TypeDanger, text) 38 | 39 | text = "ddd" 40 | Warning(ctx, text) 41 | assertMsg(TypeWarning, text) 42 | 43 | text = "eee" 44 | Set(ctx, TypeSuccess, text) 45 | assertMsg(TypeSuccess, text) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/repos/notifierrepo/push_notifications_fcm_test.go: -------------------------------------------------------------------------------- 1 | package notifierrepo_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/gofrs/uuid" 8 | "github.com/mikestefanello/pagoda/ent/notificationpermission" 9 | "github.com/mikestefanello/pagoda/pkg/domain" 10 | "github.com/mikestefanello/pagoda/pkg/repos/notifierrepo" 11 | "github.com/mikestefanello/pagoda/pkg/repos/profilerepo" 12 | storagerepo "github.com/mikestefanello/pagoda/pkg/repos/storage" 13 | "github.com/mikestefanello/pagoda/pkg/repos/subscriptions" 14 | "github.com/mikestefanello/pagoda/pkg/tests" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestFcmHasPermissionsLeftAndTokenIsRegistered(t *testing.T) { 19 | client, ctx := tests.CreateTestContainerPostgresEntClient(t) 20 | defer client.Close() 21 | 22 | // Create user and profile. 23 | user1 := tests.CreateUser(ctx, client, "User", "user1@example.com", "password", true) 24 | subscriptionsRepo := subscriptions.NewSubscriptionsRepo(client, 10, 10) 25 | profileRepo := profilerepo.NewProfileRepo(client, storagerepo.NewMockStorageClient(), subscriptionsRepo) 26 | 27 | profile1, err := profileRepo.CreateProfile( 28 | ctx, user1, "bio", 29 | time.Now().AddDate(-25, 0, 0), nil, nil, 30 | ) 31 | assert.Nil(t, err) 32 | 33 | // Set permissions 34 | uuidToken, err := uuid.NewV7(uuid.MicrosecondPrecision) 35 | assert.NoError(t, err) 36 | _, err = client.NotificationPermission.Create(). 37 | SetProfileID(profile1.ID). 38 | SetPermission(notificationpermission.Permission(domain.NotificationPermissionDailyReminder.Value)). 39 | SetPlatform(notificationpermission.Platform(domain.NotificationPlatformPush.Value)). 40 | SetToken(uuidToken.String()). 41 | Save(ctx) 42 | assert.Nil(t, err) 43 | 44 | fcmPushNotificationsRepo, err := notifierrepo.NewFcmPushNotificationsRepo(client, nil) 45 | assert.Nil(t, err) 46 | 47 | err = fcmPushNotificationsRepo.AddPushSubscription(ctx, profile1.ID, notifierrepo.FcmSubscription{ 48 | Token: "12345", 49 | }) 50 | assert.Nil(t, err) 51 | 52 | hasPermissionsLeft, err := fcmPushNotificationsRepo.HasPermissionsLeftAndTokenIsRegistered(ctx, profile1.ID, "12345") 53 | assert.Nil(t, err) 54 | // TODO: the below is False even though it would normally be True, because 55 | // I did not explicitly add a permission, and fcmPushNotificationsRepo is breaking 56 | // walls of responsability by putting its hands in permissions. Bad design. To rework. 57 | assert.False(t, hasPermissionsLeft) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/routing/routes/about.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/types" 6 | "github.com/mikestefanello/pagoda/templates" 7 | "github.com/mikestefanello/pagoda/templates/layouts" 8 | "github.com/mikestefanello/pagoda/templates/pages" 9 | 10 | "github.com/labstack/echo/v4" 11 | ) 12 | 13 | type ( 14 | about struct { 15 | ctr controller.Controller 16 | } 17 | ) 18 | 19 | func NewAboutUsRoute(ctr controller.Controller) about { 20 | return about{ 21 | ctr: ctr, 22 | } 23 | } 24 | 25 | func (c *about) Get(ctx echo.Context) error { 26 | 27 | page := controller.NewPage(ctx) 28 | page.Layout = layouts.Main 29 | page.Name = templates.PageAbout 30 | page.Component = pages.About(&page) 31 | page.Data = types.AboutData{ 32 | SupportEmail: c.ctr.Container.Config.App.SupportEmail, 33 | } 34 | page.HTMX.Request.Boosted = true 35 | 36 | return c.ctr.RenderPage(ctx, page) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/routing/routes/about_test.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | routeNames "github.com/mikestefanello/pagoda/pkg/routing/routenames" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // Simple example of how to test routes and their markup using the test HTTP server spun up within 12 | // this test package 13 | func TestAbout_Get(t *testing.T) { 14 | t.Skip("Skipping TestAbout_Get for now") 15 | 16 | doc := request(t). 17 | setRoute(routeNames.RouteNameAboutUs). 18 | get(). 19 | assertStatusCode(http.StatusOK). 20 | toDoc() 21 | 22 | // Goquery is an excellent package to use for testing HTML markup 23 | h1 := doc.Find("h1.title") 24 | assert.Len(t, h1.Nodes, 1) 25 | assert.Equal(t, "About", h1.Text()) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/routing/routes/clear_site_cookie.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/mikestefanello/pagoda/pkg/controller" 8 | "github.com/mikestefanello/pagoda/pkg/repos/msg" 9 | "github.com/mikestefanello/pagoda/pkg/routing/routenames" 10 | ) 11 | 12 | type ( 13 | clearCookie struct { 14 | ctr controller.Controller 15 | } 16 | ) 17 | 18 | func NewClearCookiesRoute(ctr controller.Controller) clearCookie { 19 | return clearCookie{ 20 | ctr: ctr, 21 | } 22 | } 23 | 24 | func (ck *clearCookie) Get(ctx echo.Context) error { 25 | if err := ck.ctr.Container.Auth.Logout(ctx); err == nil { 26 | msg.Success(ctx, "You have successfully cleared this site's cookie.") 27 | } else { 28 | msg.Danger(ctx, "An error occurred. Please try again.") 29 | } 30 | 31 | // Clear all other cookies 32 | for _, cookie := range ctx.Cookies() { 33 | cookie.Expires = time.Now().UTC().Add(-100 * time.Hour) // Set to a time in the past 34 | cookie.MaxAge = -1 35 | ctx.SetCookie(cookie) 36 | } 37 | return ck.ctr.Redirect(ctx, routenames.RouteNameLogin) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/routing/routes/contact.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mikestefanello/pagoda/pkg/context" 7 | "github.com/mikestefanello/pagoda/pkg/controller" 8 | "github.com/mikestefanello/pagoda/pkg/repos/msg" 9 | 10 | "github.com/mikestefanello/pagoda/pkg/types" 11 | "github.com/mikestefanello/pagoda/templates" 12 | "github.com/mikestefanello/pagoda/templates/layouts" 13 | "github.com/mikestefanello/pagoda/templates/pages" 14 | 15 | "github.com/labstack/echo/v4" 16 | ) 17 | 18 | type ( 19 | contact struct { 20 | controller.Controller 21 | } 22 | ) 23 | 24 | func (c *contact) Get(ctx echo.Context) error { 25 | page := controller.NewPage(ctx) 26 | page.Layout = layouts.Main 27 | page.Name = templates.PageContact 28 | page.Title = "Contact us" 29 | page.Form = &types.ContactForm{} 30 | page.Component = pages.Contact(&page) 31 | page.HTMX.Request.Boosted = true 32 | 33 | if form := ctx.Get(context.FormKey); form != nil { 34 | page.Form = form.(*types.ContactForm) 35 | } 36 | msg.Success(ctx, "Success!") 37 | msg.Warning(ctx, "Warning!") 38 | msg.Danger(ctx, "Danger!") 39 | msg.Info(ctx, "Info!") 40 | 41 | return c.RenderPage(ctx, page) 42 | } 43 | 44 | func (c *contact) Post(ctx echo.Context) error { 45 | var form types.ContactForm 46 | ctx.Set(context.FormKey, &form) 47 | 48 | // Parse the form values 49 | if err := ctx.Bind(&form); err != nil { 50 | return c.Fail(err, "unable to bind form") 51 | } 52 | 53 | if err := form.Submission.Process(ctx, form); err != nil { 54 | return c.Fail(err, "unable to process form submission") 55 | } 56 | 57 | if !form.Submission.HasErrors() { 58 | err := c.Container.Mail. 59 | Compose(). 60 | To(form.Email). 61 | Subject("Contact form submitted"). 62 | Body(fmt.Sprintf("The message is: %s", form.Message)). 63 | Send(ctx.Request().Context()) 64 | 65 | if err != nil { 66 | return c.Fail(err, "unable to send email") 67 | } 68 | } 69 | 70 | return c.Get(ctx) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/routing/routes/docs.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/mikestefanello/pagoda/pkg/controller" 6 | "github.com/mikestefanello/pagoda/templates" 7 | "github.com/mikestefanello/pagoda/templates/layouts" 8 | "github.com/mikestefanello/pagoda/templates/pages" 9 | ) 10 | 11 | type docsRoute struct { 12 | ctr controller.Controller 13 | } 14 | 15 | func NewDocsRoute(ctr controller.Controller) *docsRoute { 16 | return &docsRoute{ 17 | ctr: ctr, 18 | } 19 | } 20 | 21 | func (w *docsRoute) GetDocsHome(ctx echo.Context) error { 22 | page := controller.NewPage(ctx) 23 | page.Layout = layouts.Documentation 24 | page.Name = templates.PageWiki 25 | page.Title = "Introduction" 26 | page.Component = pages.DocumentationLandingPage(&page) 27 | page.HTMX.Request.Boosted = true 28 | 29 | return w.ctr.RenderPage(ctx, page) 30 | } 31 | 32 | func (w *docsRoute) GetDocsGettingStarted(ctx echo.Context) error { 33 | page := controller.NewPage(ctx) 34 | page.Layout = layouts.Documentation 35 | page.Name = templates.PageWiki 36 | page.Title = "Architecture" 37 | page.Component = pages.DocumentationArchitecturePage(&page) 38 | page.HTMX.Request.Boosted = true 39 | 40 | return w.ctr.RenderPage(ctx, page) 41 | } 42 | 43 | func (w *docsRoute) GetDocsGuidedTour(ctx echo.Context) error { 44 | page := controller.NewPage(ctx) 45 | page.Layout = layouts.Documentation 46 | page.Name = templates.PageWiki 47 | page.Title = "Architecture" 48 | page.Component = pages.DocumentationArchitecturePage(&page) 49 | page.HTMX.Request.Boosted = true 50 | 51 | return w.ctr.RenderPage(ctx, page) 52 | } 53 | 54 | func (w *docsRoute) GetDocsArchitecture(ctx echo.Context) error { 55 | page := controller.NewPage(ctx) 56 | page.Layout = layouts.Documentation 57 | page.Name = templates.PageWiki 58 | page.Title = "Architecture" 59 | page.Component = pages.DocumentationArchitecturePage(&page) 60 | page.HTMX.Request.Boosted = true 61 | 62 | return w.ctr.RenderPage(ctx, page) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/routing/routes/error.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/mikestefanello/pagoda/pkg/context" 7 | "github.com/mikestefanello/pagoda/pkg/controller" 8 | "github.com/mikestefanello/pagoda/templates" 9 | "github.com/mikestefanello/pagoda/templates/layouts" 10 | "github.com/mikestefanello/pagoda/templates/pages" 11 | 12 | "github.com/labstack/echo/v4" 13 | ) 14 | 15 | type errorHandler struct { 16 | ctr controller.Controller 17 | } 18 | 19 | func NewErrorHandler(ctr controller.Controller) errorHandler { 20 | return errorHandler{ 21 | ctr: ctr, 22 | } 23 | } 24 | 25 | func (e *errorHandler) Get(err error, ctx echo.Context) { 26 | if ctx.Response().Committed || context.IsCanceledError(err) { 27 | return 28 | } 29 | 30 | code := http.StatusInternalServerError 31 | if he, ok := err.(*echo.HTTPError); ok { 32 | code = he.Code 33 | } 34 | 35 | if code >= 500 { 36 | ctx.Logger().Error(err) 37 | } else { 38 | ctx.Logger().Info(err) 39 | } 40 | 41 | page := controller.NewPage(ctx) 42 | // page.Title = http.StatusText(code) 43 | page.Layout = layouts.Main 44 | page.Name = templates.PageError 45 | page.StatusCode = code 46 | page.HTMX.Request.Enabled = false 47 | page.HTMX.Request.Boosted = true 48 | 49 | page.Component = pages.Error(&page) 50 | 51 | if err = e.ctr.RenderPage(ctx, page); err != nil { 52 | ctx.Logger().Error(err) 53 | } 54 | } 55 | 56 | func (e *errorHandler) GetHttp400BadRequest(ctx echo.Context) error { 57 | e.Get(echo.NewHTTPError(http.StatusBadRequest, "Bad Request"), ctx) 58 | return nil 59 | } 60 | 61 | func (e *errorHandler) GetHttp401Unauthorized(ctx echo.Context) error { 62 | e.Get(echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized"), ctx) 63 | return nil 64 | } 65 | 66 | func (e *errorHandler) GetHttp403Forbidden(ctx echo.Context) error { 67 | e.Get(echo.NewHTTPError(http.StatusForbidden, "Forbidden"), ctx) 68 | return nil 69 | } 70 | 71 | func (e *errorHandler) GetHttp404NotFound(ctx echo.Context) error { 72 | e.Get(echo.NewHTTPError(http.StatusNotFound, "Not Found"), ctx) 73 | return nil 74 | } 75 | 76 | func (e *errorHandler) GetHttp500InternalServerError(ctx echo.Context) error { 77 | e.Get(echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error"), ctx) 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /pkg/routing/routes/healthcheck.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | 6 | "github.com/mikestefanello/pagoda/pkg/controller" 7 | "github.com/mikestefanello/pagoda/templates" 8 | "github.com/mikestefanello/pagoda/templates/layouts" 9 | "github.com/mikestefanello/pagoda/templates/pages" 10 | ) 11 | 12 | type ( 13 | healthcheck struct { 14 | ctr controller.Controller 15 | } 16 | ) 17 | 18 | func NewHealthCheckRoute(ctr controller.Controller) healthcheck { 19 | return healthcheck{ 20 | ctr: ctr, 21 | } 22 | } 23 | 24 | func (g *healthcheck) Get(ctx echo.Context) error { 25 | page := controller.NewPage(ctx) 26 | page.Layout = layouts.Main 27 | page.Name = templates.PageHealthcheck 28 | page.Component = pages.HealthCheck(&page) 29 | page.Cache.Enabled = false 30 | 31 | return g.ctr.RenderPage(ctx, page) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/routing/routes/helpers.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import "net/url" 4 | 5 | // AddQueryParam takes a URL, key, and value and returns the URL with the added query parameter. 6 | func AddQueryParam(urlStr, key, value string) (string, error) { 7 | // Parse the URL to get a url.URL struct. 8 | parsedURL, err := url.Parse(urlStr) 9 | if err != nil { 10 | return "", err 11 | } 12 | 13 | // Create a Values object from the parsed URL's query string. 14 | values := parsedURL.Query() 15 | 16 | // Add the key-value pair to the URL query parameters. 17 | values.Add(key, value) 18 | 19 | // Encode the query parameters and assign it back to the URL's RawQuery. 20 | parsedURL.RawQuery = values.Encode() 21 | 22 | // Return the updated URL as a string. 23 | return parsedURL.String(), nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/routing/routes/home.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mikestefanello/pagoda/pkg/controller" 7 | "github.com/mikestefanello/pagoda/pkg/types" 8 | "github.com/mikestefanello/pagoda/templates" 9 | "github.com/mikestefanello/pagoda/templates/layouts" 10 | "github.com/mikestefanello/pagoda/templates/pages" 11 | 12 | "github.com/labstack/echo/v4" 13 | ) 14 | 15 | type ( 16 | home struct { 17 | controller.Controller 18 | } 19 | ) 20 | 21 | func (c *home) Get(ctx echo.Context) error { 22 | page := controller.NewPage(ctx) 23 | 24 | if page.AuthUser != nil { 25 | return c.Redirect(ctx, "dashboard") 26 | 27 | } 28 | 29 | page.Layout = layouts.Main 30 | page.Name = templates.PageHome 31 | page.Metatags.Description = "Welcome to the homepage." 32 | page.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"} 33 | page.Pager = controller.NewPager(ctx, 4) 34 | page.Data = c.fetchPosts(&page.Pager) 35 | page.Component = pages.Home(&page) 36 | page.HTMX.Request.Boosted = true 37 | 38 | return c.RenderPage(ctx, page) 39 | } 40 | 41 | // fetchPosts is an mock example of fetching posts to illustrate how paging works 42 | func (c *home) fetchPosts(pager *controller.Pager) []types.Post { 43 | pager.SetItems(20) 44 | posts := make([]types.Post, 20) 45 | 46 | for k := range posts { 47 | posts[k] = types.Post{ 48 | Title: fmt.Sprintf("Post example #%d", k+1), 49 | Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1), 50 | } 51 | } 52 | 53 | return posts[pager.GetOffset() : pager.GetOffset()+pager.ItemsPerPage] 54 | } 55 | -------------------------------------------------------------------------------- /pkg/routing/routes/install_app.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/mikestefanello/pagoda/pkg/controller" 6 | "github.com/mikestefanello/pagoda/templates" 7 | "github.com/mikestefanello/pagoda/templates/layouts" 8 | "github.com/mikestefanello/pagoda/templates/pages" 9 | ) 10 | 11 | type ( 12 | installApp struct { 13 | ctr controller.Controller 14 | } 15 | ) 16 | 17 | func NewInstallAppRoute( 18 | ctr controller.Controller, 19 | ) installApp { 20 | return installApp{ 21 | ctr: ctr, 22 | } 23 | } 24 | 25 | func (c *installApp) GetInstallPage(ctx echo.Context) error { 26 | page := controller.NewPage(ctx) 27 | page.Layout = layouts.Main 28 | page.Name = templates.PageInstallApp 29 | page.Component = pages.InstallApp(&page) 30 | page.HTMX.Request.Boosted = true 31 | 32 | return c.ctr.RenderPage(ctx, page) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/routing/routes/logout.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/repos/msg" 6 | routeNames "github.com/mikestefanello/pagoda/pkg/routing/routenames" 7 | 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | type logout struct { 12 | ctr controller.Controller 13 | } 14 | 15 | func NewLogoutRoute(ctr controller.Controller) *logout { 16 | return &logout{ctr: ctr} 17 | } 18 | 19 | func (l *logout) Get(c echo.Context) error { 20 | if err := l.ctr.Container.Auth.Logout(c); err == nil { 21 | 22 | } else { 23 | msg.Danger(c, "An error occurred. Please try again.") 24 | } 25 | return l.ctr.Redirect(c, routeNames.RouteNameLandingPage) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/routing/routes/privacy.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/types" 6 | "github.com/mikestefanello/pagoda/templates" 7 | "github.com/mikestefanello/pagoda/templates/layouts" 8 | "github.com/mikestefanello/pagoda/templates/pages" 9 | 10 | "github.com/labstack/echo/v4" 11 | ) 12 | 13 | type ( 14 | privacyPolicy struct { 15 | ctr controller.Controller 16 | } 17 | ) 18 | 19 | func NewPrivacyPolicyRoute(ctr controller.Controller) privacyPolicy { 20 | return privacyPolicy{ 21 | ctr: ctr, 22 | } 23 | } 24 | 25 | func (c *privacyPolicy) Get(ctx echo.Context) error { 26 | 27 | page := controller.NewPage(ctx) 28 | page.Layout = layouts.Main 29 | page.Name = templates.PagePrivacyPolicy 30 | page.Component = pages.PrivacyPolicy(&page) 31 | page.Data = types.AboutData{ 32 | SupportEmail: c.ctr.Container.Config.App.SupportEmail, 33 | } 34 | 35 | page.HTMX.Request.Boosted = true 36 | 37 | return c.ctr.RenderPage(ctx, page) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/routing/routes/verify_email.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/mikestefanello/pagoda/ent" 6 | "github.com/mikestefanello/pagoda/ent/user" 7 | "github.com/mikestefanello/pagoda/pkg/context" 8 | "github.com/mikestefanello/pagoda/pkg/controller" 9 | "github.com/mikestefanello/pagoda/pkg/repos/msg" 10 | routeNames "github.com/mikestefanello/pagoda/pkg/routing/routenames" 11 | ) 12 | 13 | type verifyEmail struct { 14 | ctr controller.Controller 15 | } 16 | 17 | func NewVerifyEmailRoute(ctr controller.Controller) *verifyEmail { 18 | return &verifyEmail{ctr: ctr} 19 | } 20 | func (c *verifyEmail) Get(ctx echo.Context) error { 21 | var usr *ent.User 22 | 23 | // Validate the token 24 | token := ctx.Param("token") 25 | email, err := c.ctr.Container.Auth.ValidateEmailVerificationToken(token) 26 | if err != nil { 27 | msg.Warning(ctx, "The link is either invalid or has expired.") 28 | return c.ctr.Redirect(ctx, routeNames.RouteNameLandingPage) 29 | } 30 | 31 | // Check if it matches the authenticated user 32 | u := ctx.Get(context.AuthenticatedUserKey) 33 | if u != nil { 34 | authUser := u.(*ent.User) 35 | 36 | if authUser.Email == email { 37 | usr = authUser 38 | } 39 | } 40 | 41 | // Query to find a matching user, if needed 42 | if usr == nil { 43 | usr, err = c.ctr.Container.ORM.User. 44 | Query(). 45 | Where(user.Email(email)). 46 | Only(ctx.Request().Context()) 47 | 48 | if err != nil { 49 | return c.ctr.Fail(err, "query failed loading email verification token user") 50 | } 51 | } 52 | 53 | // Verify the user, if needed 54 | if !usr.Verified { 55 | _, err = usr. 56 | Update(). 57 | SetVerified(true). 58 | Save(ctx.Request().Context()) 59 | 60 | if err != nil { 61 | return c.ctr.Fail(err, "failed to set user as verified") 62 | } 63 | } 64 | 65 | msg.Success(ctx, "Your email has been successfully verified.") 66 | 67 | // If we have a user, they are already logged in and just redirect them to their home feed 68 | if u != nil { 69 | return c.ctr.Redirect(ctx, routeNames.RouteNamePreferences) 70 | 71 | } 72 | return c.ctr.Redirect(ctx, routeNames.RouteNameLogin) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/routing/routes/verify_email_subscription.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/mikestefanello/pagoda/pkg/controller" 6 | "github.com/mikestefanello/pagoda/pkg/repos/emailsmanager" 7 | "github.com/mikestefanello/pagoda/templates/layouts" 8 | ) 9 | 10 | type verifyEmailSubscription struct { 11 | ctr controller.Controller 12 | emailSubscriptionRepo emailsmanager.EmailSubscriptionRepo 13 | } 14 | 15 | func NewVerifyEmailSubscriptionRoute( 16 | ctr controller.Controller, emailSubscriptionRepo emailsmanager.EmailSubscriptionRepo, 17 | ) verifyEmailSubscription { 18 | 19 | return verifyEmailSubscription{ 20 | ctr: ctr, 21 | emailSubscriptionRepo: emailSubscriptionRepo, 22 | } 23 | } 24 | 25 | type SubscriptionData struct { 26 | Suceeded bool 27 | SignupEnabled bool 28 | } 29 | 30 | func (c *verifyEmailSubscription) Get(ctx echo.Context) error { 31 | page := controller.NewPage(ctx) 32 | page.Layout = layouts.Main 33 | page.Name = "subscribe-confirmation" 34 | 35 | // Validate the token 36 | token := ctx.Param("token") 37 | 38 | err := c.emailSubscriptionRepo.ConfirmSubscription(ctx.Request().Context(), token) 39 | if err != nil { 40 | page.Data = SubscriptionData{Suceeded: false, SignupEnabled: false} 41 | } else { 42 | page.Data = SubscriptionData{Suceeded: true, SignupEnabled: false} 43 | } 44 | 45 | return c.ctr.RenderPage(ctx, page) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/services/cache_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestCacheClient(t *testing.T) { 14 | type cacheTest struct { 15 | Value string 16 | } 17 | // Cache some data 18 | data := cacheTest{Value: "abcdef"} 19 | group := "testgroup" 20 | key := "testkey" 21 | err := c.Cache. 22 | Set(). 23 | Group(group). 24 | Key(key). 25 | Data(data). 26 | Save(context.Background()) 27 | require.NoError(t, err) 28 | 29 | // Get the data 30 | fromCache, err := c.Cache. 31 | Get(). 32 | Group(group). 33 | Key(key). 34 | Type(new(cacheTest)). 35 | Fetch(context.Background()) 36 | require.NoError(t, err) 37 | cast, ok := fromCache.(*cacheTest) 38 | require.True(t, ok) 39 | assert.Equal(t, data, *cast) 40 | 41 | // The same key with the wrong group should fail 42 | _, err = c.Cache. 43 | Get(). 44 | Key(key). 45 | Type(new(cacheTest)). 46 | Fetch(context.Background()) 47 | assert.Error(t, err) 48 | 49 | // Flush the data 50 | err = c.Cache. 51 | Flush(). 52 | Group(group). 53 | Key(key). 54 | Execute(context.Background()) 55 | require.NoError(t, err) 56 | 57 | // The data should be gone 58 | assertFlushed := func() { 59 | // The data should be gone 60 | _, err = c.Cache. 61 | Get(). 62 | Group(group). 63 | Key(key). 64 | Type(new(cacheTest)). 65 | Fetch(context.Background()) 66 | assert.Equal(t, redis.Nil, err) 67 | } 68 | assertFlushed() 69 | 70 | // Set with tags 71 | err = c.Cache. 72 | Set(). 73 | Group(group). 74 | Key(key). 75 | Data(data). 76 | Tags("tag1"). 77 | Save(context.Background()) 78 | require.NoError(t, err) 79 | 80 | // Flush the tag 81 | err = c.Cache. 82 | Flush(). 83 | Tags("tag1"). 84 | Execute(context.Background()) 85 | require.NoError(t, err) 86 | 87 | // The data should be gone 88 | assertFlushed() 89 | 90 | // Set with expiration 91 | err = c.Cache. 92 | Set(). 93 | Group(group). 94 | Key(key). 95 | Data(data). 96 | Expiration(time.Millisecond). 97 | Save(context.Background()) 98 | require.NoError(t, err) 99 | 100 | // Wait for expiration 101 | time.Sleep(time.Millisecond * 2) 102 | 103 | // The data should be gone 104 | assertFlushed() 105 | } 106 | -------------------------------------------------------------------------------- /pkg/services/container_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewContainer(t *testing.T) { 10 | assert.NotNil(t, c.Web) 11 | assert.NotNil(t, c.Config) 12 | assert.NotNil(t, c.Validator) 13 | assert.NotNil(t, c.Cache) 14 | assert.NotNil(t, c.Database) 15 | assert.NotNil(t, c.ORM) 16 | assert.NotNil(t, c.Mail) 17 | assert.NotNil(t, c.Auth) 18 | assert.NotNil(t, c.Tasks) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/services/services_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/mikestefanello/pagoda/config" 8 | "github.com/mikestefanello/pagoda/ent" 9 | "github.com/mikestefanello/pagoda/pkg/tests" 10 | 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | var ( 15 | c *Container 16 | ctx echo.Context 17 | usr *ent.User 18 | ) 19 | 20 | func TestMain(m *testing.M) { 21 | // Set the environment to test 22 | config.SwitchEnvironment(config.EnvTest) 23 | 24 | // Create a new container 25 | c = NewContainer() 26 | 27 | // Create a web context 28 | ctx, _ = tests.NewContext(c.Web, "/") 29 | tests.InitSession(ctx) 30 | 31 | // Create a test user 32 | var err error 33 | if usr, err = tests.CreateRandomUser(c.ORM); err != nil { 34 | panic(err) 35 | } 36 | 37 | // Run tests 38 | exitVal := m.Run() 39 | 40 | // Shutdown the container 41 | if err = c.Shutdown(); err != nil { 42 | panic(err) 43 | } 44 | 45 | os.Exit(exitVal) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/services/tasks_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTaskClient_New(t *testing.T) { 11 | now := time.Now() 12 | tk := c.Tasks. 13 | New("task1"). 14 | Payload("payload"). 15 | Queue("queue"). 16 | Periodic("@every 5s"). 17 | MaxRetries(5). 18 | Timeout(5 * time.Second). 19 | Deadline(now). 20 | At(now). 21 | Wait(6 * time.Second). 22 | Retain(7 * time.Second) 23 | 24 | assert.Equal(t, "task1", tk.typ) 25 | assert.Equal(t, "payload", tk.payload.(string)) 26 | assert.Equal(t, "queue", *tk.queue) 27 | assert.Equal(t, "@every 5s", *tk.periodic) 28 | assert.Equal(t, 5, *tk.maxRetries) 29 | assert.Equal(t, 5*time.Second, *tk.timeout) 30 | assert.Equal(t, now, *tk.deadline) 31 | assert.Equal(t, now, *tk.at) 32 | assert.Equal(t, 6*time.Second, *tk.wait) 33 | assert.Equal(t, 7*time.Second, *tk.retain) 34 | assert.NoError(t, tk.Save()) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/services/validator.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/go-playground/validator/v10" 5 | ) 6 | 7 | // Validator provides validation mainly validating structs within the web context 8 | type Validator struct { 9 | // validator stores the underlying validator 10 | validator *validator.Validate 11 | } 12 | 13 | // NewValidator creats a new Validator 14 | func NewValidator() *Validator { 15 | return &Validator{ 16 | validator: validator.New(), 17 | } 18 | } 19 | 20 | // Validate validates a struct 21 | func (v *Validator) Validate(i any) error { 22 | if err := v.validator.Struct(i); err != nil { 23 | return err 24 | } 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/services/validator_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestValidator(t *testing.T) { 10 | type example struct { 11 | Value string `validate:"required"` 12 | } 13 | e := example{} 14 | err := c.Validator.Validate(e) 15 | assert.Error(t, err) 16 | e.Value = "a" 17 | err = c.Validator.Validate(e) 18 | assert.NoError(t, err) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/types/about.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ( 4 | AboutData struct { 5 | SupportEmail string 6 | } 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/types/committed.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | type ( 6 | DropdownIterable struct { 7 | ID int `json:"id"` 8 | Object any `json:"object"` 9 | } 10 | 11 | CommittedModePageData struct { 12 | Friends []DropdownIterable 13 | InvitationText string 14 | InvitationLink string 15 | } 16 | 17 | UpdateInAppModeForm struct { 18 | MatchProfileID int `form:"match_id" validate:"required"` 19 | Submission controller.FormSubmission 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /pkg/types/contact.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | type ( 6 | ContactForm struct { 7 | Email string `form:"email" validate:"required,email"` 8 | Message string `form:"message" validate:"required"` 9 | Submission controller.FormSubmission 10 | } 11 | ) 12 | -------------------------------------------------------------------------------- /pkg/types/email_subscribe.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | type ( 6 | EmailSubscriptionData struct { 7 | Description string 8 | Placeholder string 9 | Latitude float64 10 | Longitude float64 11 | } 12 | 13 | EmailSubscriptionForm struct { 14 | Email string `form:"email" validate:"required"` 15 | Latitude float64 `form:"latitude" validate:"required"` 16 | Longitude float64 `form:"longitude" validate:"required"` 17 | Submission controller.FormSubmission 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /pkg/types/emails.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ( 4 | EmailDefaultData struct { 5 | AppName string 6 | SupportEmail string 7 | Domain string 8 | ConfirmationLink string 9 | } 10 | 11 | EmailPasswordResetData struct { 12 | AppName string 13 | SupportEmail string 14 | Domain string 15 | ProfileName string 16 | PasswordResetLink string 17 | OperatingSystem string 18 | BrowserName string 19 | } 20 | 21 | QuestionInEmail struct { 22 | Question string 23 | WriteAnswerURL string 24 | } 25 | 26 | EmailUpdate struct { 27 | SelfName string 28 | AppName string 29 | SupportEmail string 30 | Domain string 31 | PartnerName string 32 | NumNewNotifications int 33 | QuestionsAnsweredByFriendButNotSelfTitle string 34 | NumQuestionsAnsweredByFriendButNotSelf int 35 | QuestionsAnsweredByFriendButNotSelf []QuestionInEmail 36 | QuestionsNotAnsweredInSocialCircle []QuestionInEmail 37 | UnsubscribeDailyUpdatesLink string 38 | UnsubscribePartnerActivityLink string 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /pkg/types/forgot_password.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | type ( 6 | ForgotPasswordForm struct { 7 | Email string `form:"email" validate:"required,email"` 8 | Submission controller.FormSubmission 9 | } 10 | ) 11 | -------------------------------------------------------------------------------- /pkg/types/home.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ( 4 | Post struct { 5 | Title string 6 | Body string 7 | } 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/types/home_feed.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ( 4 | HomeFeedData struct { 5 | NextPageURL string 6 | RanOutOfQuestions bool 7 | SupportEmail string 8 | NumFriends int 9 | JustFinishedOnboarded bool 10 | } 11 | 12 | HomeFeedButtonsData struct { 13 | NumDrafts int 14 | NumLikedQuestions int 15 | NumWaitingOnPartner int 16 | NumWaitingOnYou int 17 | MaxNumCanWaitOnYou int 18 | } 19 | 20 | HomeFeedStatsData struct { 21 | NumAnsweredInLast24H int 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /pkg/types/invitations.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ( 4 | InvitationsData struct { 5 | InvitationText string 6 | } 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/types/landing_page.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "html/template" 4 | 5 | type LandingPage struct { 6 | AppName string 7 | UserSignupEnabled bool 8 | 9 | Title string 10 | Subtitle string 11 | GetNowText string 12 | 13 | IntroTitle string 14 | IntroText template.HTML 15 | 16 | OtherAppPitchTitle string 17 | OtherAppPitchText string 18 | OtherAppURL string 19 | OtherAppName string 20 | 21 | HowItWorksTitle string 22 | 23 | Quote1 string 24 | Quote2 string 25 | 26 | ExampleQuestion1 string 27 | ExampleQuestion2 string 28 | ExampleQuestion3 string 29 | 30 | AboutUsTitle1 string 31 | AboutUsText1 string 32 | AboutUsTitle2 string 33 | AboutUsText2 string 34 | 35 | QAItems []QAItem 36 | 37 | HeroSmImageURL string 38 | HeroMdImageURL string 39 | HeroLgImageURL string 40 | BackgroundPhoto2lg string 41 | BackgroundPhoto2xl string 42 | 43 | IsPaymentEnabled bool 44 | ProductProCode string 45 | ProductProPrice string 46 | 47 | ContactEmail string 48 | } 49 | 50 | type QAItem struct { 51 | Question string 52 | Answer string 53 | } 54 | -------------------------------------------------------------------------------- /pkg/types/login.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | type ( 6 | LoginForm struct { 7 | Email string `form:"email" validate:"required,email"` 8 | Password string `form:"password" validate:"required"` 9 | Submission controller.FormSubmission 10 | } 11 | ) 12 | -------------------------------------------------------------------------------- /pkg/types/notifications.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/domain" 4 | 5 | type NormalNotificationsPageData struct { 6 | Notifications []*domain.Notification 7 | NextPageURL string 8 | } 9 | -------------------------------------------------------------------------------- /pkg/types/page_data.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/ent" 4 | 5 | type PageData struct { 6 | IsAuth bool 7 | AuthUser *ent.User 8 | Data any 9 | ToURL func(name string, params ...any) string 10 | } 11 | -------------------------------------------------------------------------------- /pkg/types/payments.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mikestefanello/pagoda/pkg/controller" 7 | "github.com/mikestefanello/pagoda/pkg/domain" 8 | ) 9 | 10 | type ( 11 | PaymentProcessorPublicKey struct { 12 | Key string `json:"key"` 13 | } 14 | 15 | CreateCheckoutSessionForm struct { 16 | Submission controller.FormSubmission 17 | PriceID string `form:"price_id", validate:required` 18 | } 19 | 20 | ProductDescription struct { 21 | Name string 22 | Subtitle string 23 | Price string 24 | Points []string 25 | ProductType domain.ProductType 26 | } 27 | PricingPageData struct { 28 | ProductProCode string 29 | ProductProPrice string 30 | ActivePlan domain.ProductType 31 | IsTrial bool 32 | SubscriptionExpiresOn *time.Time 33 | ProductDescriptions []ProductDescription 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /pkg/types/profile.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/domain" 5 | ) 6 | 7 | type ProfilePageData struct { 8 | Profile domain.Profile 9 | PhotosJSON string 10 | IsSelf bool 11 | ShowGenderAndAge bool 12 | UploadProfilePicUrl string 13 | UploadGalleryPicUrl string 14 | CanUploadMoreGalleryPics bool 15 | GalleryPicsMaxCount int 16 | JustAcceptedCommitedRequest bool 17 | } 18 | 19 | type ProfileCalendarHeatmap struct { 20 | Counts []CountByDay 21 | } 22 | type CountByDay struct { 23 | Date string `json:"date"` 24 | Value int `json:"value"` 25 | } 26 | 27 | type LocalizationPageData struct { 28 | Latitude float64 29 | Longitude float64 30 | Radius int 31 | PostGeoDataURL string 32 | } 33 | -------------------------------------------------------------------------------- /pkg/types/register.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | type ( 6 | RegisterForm struct { 7 | RelationshipStatus string `form:"relationship_status" validate:"required"` 8 | Name string `form:"name" validate:"required"` 9 | Email string `form:"email" validate:"required,email"` 10 | Password string `form:"password" validate:"required"` 11 | Birthdate string `form:"birthdate" validate:"required"` 12 | Submission controller.FormSubmission 13 | } 14 | 15 | RegisterData struct { 16 | RelationshipStatus string 17 | UserSignupEnabled bool 18 | MinDate string 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /pkg/types/reset_password.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | type ( 6 | ResetPasswordForm struct { 7 | Password string `form:"password" validate:"required"` 8 | ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` 9 | Submission controller.FormSubmission 10 | } 11 | ) 12 | -------------------------------------------------------------------------------- /pkg/types/search.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ( 4 | SearchResult struct { 5 | Title string 6 | URL string 7 | } 8 | ) 9 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/next-steps.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/100.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/144.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/50.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/72.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "properties" : { 7 | "compression-type" : "gpu-optimized-smallest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "scale" : "3x" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/LaunchIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "compression-type" : "automatic", 5 | "filename" : "launch-64.png", 6 | "idiom" : "universal", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "compression-type" : "automatic", 11 | "filename" : "launch-128.png", 12 | "idiom" : "universal", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "compression-type" : "automatic", 17 | "filename" : "launch-192.png", 18 | "idiom" : "universal", 19 | "scale" : "3x" 20 | } 21 | ], 22 | "info" : { 23 | "author" : "xcode", 24 | "version" : 1 25 | }, 26 | "properties" : { 27 | "compression-type" : "automatic" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/LaunchIcon.imageset/launch-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/LaunchIcon.imageset/launch-128.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/LaunchIcon.imageset/launch-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/LaunchIcon.imageset/launch-192.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/LaunchIcon.imageset/launch-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/Cherie/Assets.xcassets/LaunchIcon.imageset/launch-64.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Entitlements/.gitignore: -------------------------------------------------------------------------------- 1 | /GoogleService-Info.plist 2 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Entitlements/Entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | production 7 | com.apple.developer.associated-domains 8 | 9 | applinks:cherie.chatbond.app 10 | webcredentials:cherie.chatbond.app 11 | 12 | com.apple.security.app-sandbox 13 | 14 | com.apple.security.files.user-selected.read-write 15 | 16 | com.apple.security.network.client 17 | 18 | com.apple.security.personal-information.location 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API_KEY 6 | AIzaSyA58RRn0Md1IiiiMccoTf4ieCsymSTR8rc 7 | GCM_SENDER_ID 8 | 228226158473 9 | PLIST_VERSION 10 | 1 11 | BUNDLE_ID 12 | app.chatbond.cherie 13 | PROJECT_ID 14 | cherie-407714 15 | STORAGE_BUCKET 16 | cherie-407714.appspot.com 17 | IS_ADS_ENABLED 18 | 19 | IS_ANALYTICS_ENABLED 20 | 21 | IS_APPINVITE_ENABLED 22 | 23 | IS_GCM_ENABLED 24 | 25 | IS_SIGNIN_ENABLED 26 | 27 | GOOGLE_APP_ID 28 | 1:228226158473:ios:3992b9065b37a8fa699b58 29 | 30 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Printer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | 4 | func printView(webView: WKWebView){ 5 | let printController = UIPrintInteractionController.shared 6 | 7 | let printInfo = UIPrintInfo(dictionary:nil) 8 | printInfo.outputType = UIPrintInfo.OutputType.general 9 | printInfo.jobName = (webView.url?.absoluteString)! 10 | printInfo.duplex = UIPrintInfo.Duplex.none 11 | printInfo.orientation = UIPrintInfo.Orientation.portrait 12 | 13 | printController.printPageRenderer = UIPrintPageRenderer() 14 | 15 | printController.printPageRenderer?.addPrintFormatter(webView.viewPrintFormatter(), startingAtPageAt: 0) 16 | 17 | printController.printInfo = printInfo 18 | printController.showsNumberOfCopies = true 19 | printController.present(animated: true) 20 | } 21 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Cherie/Settings.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | struct Cookie { 4 | var name: String 5 | var value: String 6 | } 7 | 8 | let gcmMessageIDKey = "00000000000" // update this with actual ID if using Firebase 9 | 10 | // URL for first launch 11 | let rootUrl = URL(string: "https://cherie.chatbond.app")! 12 | 13 | // allowed origin is for what we are sticking to pwa domain 14 | // This should also appear in Info.plist 15 | let allowedOrigins: [String] = ["cherie.chatbond.app"] 16 | 17 | // auth origins will open in modal and show toolbar for back into the main origin. 18 | // These should also appear in Info.plist 19 | let authOrigins: [String] = [] 20 | // allowedOrigins + authOrigins <= 10 21 | 22 | let platformCookie = Cookie(name: "app-platform", value: "iOS App Store") 23 | 24 | // UI options 25 | let displayMode = "standalone" // standalone / fullscreen. 26 | let adaptiveUIStyle = true // iOS 15+ only. Change app theme on the fly to dark/light related to WebView background color. 27 | let overrideStatusBar = false // iOS 13-14 only. if you don't support dark/light system theme. 28 | let statusBarTheme = "dark" // dark / light, related to override option. 29 | let pullToRefresh = true // Enable/disable pull down to refresh page 30 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment the next line to define a global platform for your project 2 | platform :ios, '15.0' 3 | 4 | target 'Cherie' do 5 | # Comment the next line if you don't want to use dynamic frameworks 6 | use_frameworks! 7 | 8 | # Add the pod for Firebase Cloud Messaging 9 | pod 'Firebase/Messaging' 10 | 11 | end 12 | 13 | post_install do |installer| 14 | installer.pods_project.targets.each do |target| 15 | target.build_configurations.each do |config| 16 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' 17 | end 18 | end 19 | end -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/launch-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/launch-128.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/launch-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/launch-192.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/launch-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/launch-256.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/launch-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/launch-512.png -------------------------------------------------------------------------------- /pwabuilder-ios-wrapper/src/launch-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/pwabuilder-ios-wrapper/src/launch-64.png -------------------------------------------------------------------------------- /seeder/seeder_test.go: -------------------------------------------------------------------------------- 1 | package seeder_test 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/jackc/pgx/stdlib" 8 | "github.com/mikestefanello/pagoda/config" 9 | "github.com/mikestefanello/pagoda/pkg/tests" 10 | "github.com/mikestefanello/pagoda/seeder" 11 | ) 12 | 13 | func init() { 14 | // Register "pgx" as "postgres" explicitly for database/sql 15 | sql.Register("postgres", stdlib.GetDefaultDriver()) 16 | } 17 | 18 | // TestSeeder tests the seeder code, confirming that it runs. 19 | func TestSeeder(t *testing.T) { 20 | client, _ := tests.CreateTestContainerPostgresEntClient(t) 21 | defer client.Close() 22 | 23 | config := config.Config{} 24 | seeder.RunIdempotentSeeder(&config, client) 25 | 26 | // Assert stuff 27 | 28 | // Seeder should be idempotent and not throw exceptions if re-run 29 | seeder.RunIdempotentSeeder(&config, client) 30 | } 31 | -------------------------------------------------------------------------------- /static/cherie_pwa_apple-icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/static/cherie_pwa_apple-icon-180.png -------------------------------------------------------------------------------- /static/cherie_pwa_manifest-icon-192.maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/static/cherie_pwa_manifest-icon-192.maskable.png -------------------------------------------------------------------------------- /static/cherie_pwa_manifest-icon-512.maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/static/cherie_pwa_manifest-icon-512.maskable.png -------------------------------------------------------------------------------- /static/cherie_pwa_manifest-icon-96.maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/static/cherie_pwa_manifest-icon-96.maskable.png -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/static/favicon.png -------------------------------------------------------------------------------- /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/static/icon.png -------------------------------------------------------------------------------- /static/meta_vanilla_bundle.json: -------------------------------------------------------------------------------- 1 | {"inputs":{"javascript/vanilla/cal_heatmap.js":{"bytes":6362,"imports":[],"format":"esm"},"javascript/vanilla/load_scripts_and_styles.js":{"bytes":1201,"imports":[],"format":"esm"},"javascript/vanilla/main.js":{"bytes":2455,"imports":[{"path":"javascript/vanilla/cal_heatmap.js","kind":"import-statement","original":"./cal_heatmap"},{"path":"javascript/vanilla/load_scripts_and_styles.js","kind":"import-statement","original":"./load_scripts_and_styles"}],"format":"esm"}},"outputs":{"static/vanilla_bundle.js.map":{"imports":[],"exports":[],"inputs":{},"bytes":15804},"static/vanilla_bundle.js":{"imports":[],"exports":[],"entryPoint":"javascript/vanilla/main.js","inputs":{"javascript/vanilla/cal_heatmap.js":{"bytesInOutput":2636},"javascript/vanilla/load_scripts_and_styles.js":{"bytesInOutput":416},"javascript/vanilla/main.js":{"bytesInOutput":1019}},"bytes":4139}}} -------------------------------------------------------------------------------- /styles/styles.css: -------------------------------------------------------------------------------- 1 | @import './tailwind_components.css'; 2 | 3 | @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap'); 4 | 5 | @tailwind base; 6 | @tailwind components; 7 | @tailwind utilities; 8 | 9 | 10 | [x-cloak] { 11 | display: none !important; 12 | } 13 | 14 | :root { 15 | --brightness-normal: 1; 16 | /* No change by default */ 17 | --brightness-hover-light: 0.15; 18 | /* Lighter in light mode */ 19 | --brightness-hover-dark: 0.3; 20 | /* Darker in dark mode */ 21 | } 22 | 23 | .hover-brightness { 24 | transition: filter 0.3s ease; 25 | } 26 | 27 | .hover-brightness:hover { 28 | transition: filter 0.3s ease; 29 | background-image: linear-gradient(rgba(0, 0, 0, var(--brightness-hover)), rgba(0, 0, 0, var(--brightness-hover))); 30 | } 31 | 32 | .tiptap .is-empty:first-child:before { 33 | @apply text-slate-300; 34 | @apply content-[attr(data-placeholder)]; 35 | @apply float-left; 36 | @apply pointer-events-none; 37 | @apply h-0; 38 | } -------------------------------------------------------------------------------- /styles/tailwind_components.css: -------------------------------------------------------------------------------- 1 | /* .label { 2 | @apply block text-sm font-medium text-gray-700 dark: text-gray-100; 3 | } */ -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./javascript/**/*.{js,svelte}", 4 | "./**/*.templ", 5 | "./node_modules/flowbite/**/*.js", 6 | ], 7 | theme: { 8 | extend: { 9 | backdropBlur: { 10 | xs: "2px", 11 | }, 12 | fontFamily: { 13 | PlayfairDisplay: ["Playfair Display", "serif"], 14 | }, 15 | }, 16 | }, 17 | // https://themes.ionevolve.com/ 18 | daisyui: { 19 | themes: [ 20 | { 21 | lightmode: { 22 | // Change to any existing daisyui theme or make your own 23 | ...require("daisyui/src/theming/themes")["cmyk"], 24 | // Edit styles if required 25 | primary: "white", 26 | secondary: "#DEFBFB", 27 | accent: "#FA6A7D", 28 | neutral: "#919191", 29 | "base-100": "", 30 | info: "#623CEA", 31 | success: "#87FF65", 32 | warning: "#FFC759", 33 | error: "#A30000", 34 | }, 35 | }, 36 | { 37 | darkmode: { 38 | // Change to any existing daisyui theme or make your own 39 | ...require("daisyui/src/theming/themes")["business"], 40 | // Edit styles if required 41 | primary: "#111827", 42 | secondary: "#222833", 43 | accent: "#FA6A7D", 44 | neutral: "#494949", 45 | "base-100": "#010D14", 46 | info: "#623CEA", 47 | success: "#80D569", 48 | warning: "#FFC759", 49 | error: "#A30000", 50 | }, 51 | }, 52 | ], 53 | }, 54 | plugins: [require("daisyui"), require("flowbite/plugin")], 55 | }; 56 | -------------------------------------------------------------------------------- /templates/components/accordion.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "fmt" 4 | 5 | templ AccordionItem(title string, expanded bool) { 6 |
11 | 15 |
20 |
23 | { children... } 24 |
25 |
26 |
27 | } 28 | -------------------------------------------------------------------------------- /templates/components/documentation.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ SectionTitle(title string) { 4 |

{ title }

5 | } 6 | 7 | templ SubSectionTitle(title string) { 8 |

{ title }

9 | } 10 | -------------------------------------------------------------------------------- /templates/components/empty_page_msg.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import () 4 | 5 | templ EmptyPageMessage(message, styleClasses string) { 6 |
10 | { message } 11 |
12 | } 13 | -------------------------------------------------------------------------------- /templates/components/forms.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ FormCSRF(token string) { 4 | 5 | } 6 | 7 | templ FormFieldErrors(errs []string) { 8 | for _, err := range errs { 9 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /templates/components/icons.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ TechIcon(styleClasses, websiteUrl, iconUrl, altText string) { 4 | 5 | { 11 | 12 | } 13 | 14 | templ TechIconWithDarkAndLightModes(styleClasses, websiteUrl, lightModeIconUrl, darkModeIconUrl, altText string) { 15 | 16 | { 22 | 28 | 29 | } 30 | -------------------------------------------------------------------------------- /templates/components/loading.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ PageLoadingIndicator() { 4 |
5 |
6 | 7 | 8 | 9 | 10 |
11 |
12 | 25 | } 26 | 27 | templ BottomLoadingIndicator() { 28 |
29 |
30 | 31 | 32 | 33 | 34 |
35 |
36 | 49 | } 50 | -------------------------------------------------------------------------------- /templates/components/payments.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/domain" 6 | "github.com/mikestefanello/pagoda/pkg/routing/routenames" 7 | ) 8 | 9 | templ ManageSubscriptionButton(page *controller.Page, subscription domain.ProductType, isTrial bool) { 10 | if subscription == domain.ProductTypeFree || (subscription == domain.ProductTypePro && isTrial) { 11 | @PricingPage(page) 12 | } else { 13 | // This is an existing customer 14 | @StripePortal(page) 15 | } 16 | } 17 | 18 | templ PricingPage(page *controller.Page) { 19 | 31 | } 32 | 33 | templ StripePortal(page *controller.Page) { 34 |
35 | @FormCSRF(page.CSRF) 36 | 43 |
44 | } 45 | -------------------------------------------------------------------------------- /templates/components/prev_nav.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ PrevNavBarWithTitle(prevURL, avatarURL, title string) { 4 | // NOTE: the following is a spacer for mobile view, so that the hamburger icon is not shadowed when at the top of the screen. 5 |
6 |
9 |
10 | 28 |
29 |
30 | if len(avatarURL) != 0 { 31 |
32 | Avatar 33 |
34 | } 35 |

36 | { title } 37 |

38 |
39 |
40 | 41 |
42 |
43 | } 44 | -------------------------------------------------------------------------------- /templates/components/sidebar.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | ) 6 | 7 | templ Sidebar(page *controller.Page) { 8 | 21 | } 22 | -------------------------------------------------------------------------------- /templates/components/sidebar_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package components 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/mikestefanello/pagoda/pkg/controller" 13 | ) 14 | 15 | func Sidebar(page *controller.Page) templ.Component { 16 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 17 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 18 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 19 | return templ_7745c5c3_CtxErr 20 | } 21 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 22 | if !templ_7745c5c3_IsBuffer { 23 | defer func() { 24 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 25 | if templ_7745c5c3_Err == nil { 26 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 27 | } 28 | }() 29 | } 30 | ctx = templ.InitializeContext(ctx) 31 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 32 | if templ_7745c5c3_Var1 == nil { 33 | templ_7745c5c3_Var1 = templ.NopComponent 34 | } 35 | ctx = templ.ClearChildren(ctx) 36 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 45 | if templ_7745c5c3_Err != nil { 46 | return templ_7745c5c3_Err 47 | } 48 | return nil 49 | }) 50 | } 51 | 52 | var _ = templruntime.GeneratedTemplate 53 | -------------------------------------------------------------------------------- /templates/components/theme_toggle.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ ThemeToggle(id string) { 4 |
7 | @initThemeToggle(id) 8 | } 9 | 10 | script initThemeToggle(id string ) { 11 | renderSvelteComponent('ThemeToggle', id, {}); 12 | } 13 | -------------------------------------------------------------------------------- /templates/components/tooltip.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "fmt" 4 | 5 | templ ToolTip(toolTipIndicatorText, bubbleText string) { 6 |
{ toolTipIndicatorText }
10 | } 11 | -------------------------------------------------------------------------------- /templates/components/top_banner.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ BonfireBanner() { 4 | 5 | 17 | 35 | } 36 | -------------------------------------------------------------------------------- /templates/emails/subscription_confirmation.templ: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | ) 6 | 7 | templ SubscriptionConfirmation(page *controller.Page) { 8 | } 9 | -------------------------------------------------------------------------------- /templates/emails/subscription_confirmation_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package emails 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/mikestefanello/pagoda/pkg/controller" 13 | ) 14 | 15 | func SubscriptionConfirmation(page *controller.Page) templ.Component { 16 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 17 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 18 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 19 | return templ_7745c5c3_CtxErr 20 | } 21 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 22 | if !templ_7745c5c3_IsBuffer { 23 | defer func() { 24 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 25 | if templ_7745c5c3_Err == nil { 26 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 27 | } 28 | }() 29 | } 30 | ctx = templ.InitializeContext(ctx) 31 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 32 | if templ_7745c5c3_Var1 == nil { 33 | templ_7745c5c3_Var1 = templ.NopComponent 34 | } 35 | ctx = templ.ClearChildren(ctx) 36 | return nil 37 | }) 38 | } 39 | 40 | var _ = templruntime.GeneratedTemplate 41 | -------------------------------------------------------------------------------- /templates/emails/test.templ: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | templ TestEmail() { 4 | { "Test email template. See services/mail.go to provide your implementation." } 5 | } 6 | -------------------------------------------------------------------------------- /templates/emails/test_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package emails 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func TestEmail() templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | var templ_7745c5c3_Var2 string 33 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Test email template. See services/mail.go to provide your implementation.") 34 | if templ_7745c5c3_Err != nil { 35 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/emails/test.templ`, Line: 4, Col: 81} 36 | } 37 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 38 | if templ_7745c5c3_Err != nil { 39 | return templ_7745c5c3_Err 40 | } 41 | return nil 42 | }) 43 | } 44 | 45 | var _ = templruntime.GeneratedTemplate 46 | -------------------------------------------------------------------------------- /templates/helpers/helpers.templ: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/labstack/gommon/random" 8 | "github.com/mikestefanello/pagoda/config" 9 | "html/template" 10 | "io" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | // CacheBuster stores a random string used as a cache buster for static files. 16 | CacheBuster = random.String(10) 17 | ) 18 | 19 | // File appends a cache buster to a given filepath so it can remain cached until the app is restarted 20 | func ServiceWorkerFile(filepath string) string { 21 | return fmt.Sprintf("/%s?v=%s", filepath, CacheBuster) 22 | } 23 | 24 | // File appends a cache buster to a given filepath so it can remain cached until the app is restarted 25 | func File(filepath string) string { 26 | return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, CacheBuster) 27 | } 28 | 29 | // Link outputs HTML for a link element, providing the ability to dynamically set the active class 30 | templ Link(url, text, currentPath string, classes ...string) { 31 | { text } 32 | } 33 | 34 | func isEqualValue(item, expected, val string) string { 35 | if item == expected { 36 | return val 37 | } 38 | return "" 39 | } 40 | 41 | func UnsafeHTML(s template.HTML) templ.Component { 42 | return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 43 | io.WriteString(w, string(s)) 44 | return nil 45 | }) 46 | } 47 | 48 | type Fn string 49 | 50 | func ToJSON(v any) string { 51 | b, _ := json.Marshal(v) 52 | return string(b) 53 | } 54 | 55 | func ToJS(data any, functions map[string]Fn) string { 56 | var pairs []string 57 | for k, v := range toMap(data) { 58 | pairs = append(pairs, fmt.Sprintf("%s: %s", jsonString(k), jsonString(v))) 59 | } 60 | for k, fn := range functions { 61 | pairs = append(pairs, fmt.Sprintf("%s: %s", jsonString(k), fn)) 62 | } 63 | return "{" + strings.Join(pairs, ",") + "}" 64 | } 65 | 66 | func toMap(data any) (m map[string]any) { 67 | jb, _ := json.Marshal(data) 68 | json.Unmarshal(jb, &m) 69 | return m 70 | } 71 | 72 | func jsonString(s any) string { 73 | ss, _ := json.Marshal(s) 74 | return string(ss) 75 | } 76 | -------------------------------------------------------------------------------- /templates/layouts/auth.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/templates/components" 6 | "github.com/mikestefanello/pagoda/pkg/routing/routenames" 7 | ) 8 | 9 | templ Auth(content templ.Component, page *controller.Page) { 10 | 11 | 12 | 13 | @components.Metatags(page) 14 | @components.CSS() 15 | @components.JS() 16 | 17 | 18 | 19 |
20 |
21 | 22 | 25 |
30 |
31 | 32 | @components.ThemeToggle("landing-page-theme-toggle") 33 | 34 | 35 | Logo 36 | { page.AppName } 37 | 38 | 39 |
40 |
43 | if len(page.Title) > 0 { 44 |

{ page.Title }

45 | } 46 |
47 | @components.Messages(page) 48 | @content 49 |
50 |
51 |
52 | @components.PageLoadingIndicator() 53 | // TODO: Links not working when using here 54 | // @components.TextFooter(page) 55 | @components.JSFooter(page) 56 | 57 | 58 | } 59 | -------------------------------------------------------------------------------- /templates/layouts/documentation.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/templates/components" 6 | ) 7 | 8 | templ Documentation(content templ.Component, page *controller.Page) { 9 | 10 | 11 | 12 | @components.Metatags(page) 13 | @components.CSS() 14 | @components.JS() 15 | 16 | 17 |
18 | @components.Drawer(page, true) 19 |
20 | 23 |
24 | 27 |
31 | @content 32 |
33 |
34 | @components.PageLoadingIndicator() 35 | // TODO: links not working when using here 36 | // @components.TextFooter(page) 37 | @components.JSFooter(page) 38 | 39 | 40 | } 41 | -------------------------------------------------------------------------------- /templates/layouts/email.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | templ Email(content templ.Component) { 4 | @content 5 | } 6 | -------------------------------------------------------------------------------- /templates/layouts/email_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package layouts 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func Email(content templ.Component) templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | return nil 37 | }) 38 | } 39 | 40 | var _ = templruntime.GeneratedTemplate 41 | -------------------------------------------------------------------------------- /templates/layouts/landing_page.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/templates/components" 6 | ) 7 | 8 | templ LandingPage(content templ.Component, page *controller.Page) { 9 | 10 | 11 | 12 | @components.Metatags(page) 13 | @components.CSS() 14 | @components.JS() 15 | 16 | 17 | // @components.BonfireBanner() 18 | 19 | 22 | 23 | // @components.PWAMobileInstallButton(page) 24 |
25 |
30 | @content 31 |
32 |
33 | @components.PageLoadingIndicator() 34 | // TODO: links not working when using here 35 | // @components.TextFooter(page) 36 | @components.JSFooter(page) 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /templates/layouts/main.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/templates/components" 6 | ) 7 | 8 | templ Main(content templ.Component, page *controller.Page) { 9 | 10 | 11 | 12 | @components.Metatags(page) 13 | @components.CSS() 14 | @components.JS() 15 | 16 | 17 | // @components.BonfireBanner() 18 | 19 | 20 |
21 | // @components.Drawer(page) 22 |
23 | 24 | 27 | 28 | // @components.PWAMobileInstallButton(page) 29 |
32 | // NOTE: to get below working, add "lg:ml-64" to the parent div 33 | // 36 | 37 |
41 |
42 |
43 | if len(page.Title) > 0 { 44 |

{ page.Title }

45 | } 46 | @components.Messages(page) 47 | @content 48 |
49 | if page.ShowBottomNavbar { 50 |
51 |
52 | @components.BottomNav(page) 53 |
54 | } 55 |
56 |
57 |
58 | @components.PageLoadingIndicator() 59 | // @components.TextFooter(page) 60 | @components.JSFooter(page) 61 | 62 | 63 | } 64 | -------------------------------------------------------------------------------- /templates/pages/about.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/types" 6 | "github.com/mikestefanello/pagoda/templates/components" 7 | ) 8 | 9 | templ About(page *controller.Page) { 10 | @components.PrevNavBarWithTitle("", "", "☎️ Contact us") 11 | if data, ok := page.Data.(types.AboutData); ok { 12 | @aboutUsData(data) 13 | } 14 | } 15 | 16 | templ aboutUsData(data types.AboutData) { 17 |
18 |
19 |

20 | { "We're dedicated to improving our app and we'd love to hear from you!" } 21 |

22 |

23 | { "If you have any bug reports, feature requests, or suggestions, please reach out to us. 🙏😃❤️" } 24 |

25 |

26 | { "You can send us an email by clicking on the following:" } 27 |

28 | 37 |
38 |
39 | } 40 | -------------------------------------------------------------------------------- /templates/pages/docs_architecture.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | // "github.com/mikestefanello/pagoda/templates/components" 6 | templ DocumentationArchitecturePage(page *controller.Page) { 7 | @docsPageLayout(page) { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/pages/docs_architecture_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import "github.com/mikestefanello/pagoda/pkg/controller" 12 | 13 | // "github.com/mikestefanello/pagoda/templates/components" 14 | func DocumentationArchitecturePage(page *controller.Page) templ.Component { 15 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 16 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 17 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 18 | return templ_7745c5c3_CtxErr 19 | } 20 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 21 | if !templ_7745c5c3_IsBuffer { 22 | defer func() { 23 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 24 | if templ_7745c5c3_Err == nil { 25 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 26 | } 27 | }() 28 | } 29 | ctx = templ.InitializeContext(ctx) 30 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 31 | if templ_7745c5c3_Var1 == nil { 32 | templ_7745c5c3_Var1 = templ.NopComponent 33 | } 34 | ctx = templ.ClearChildren(ctx) 35 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 36 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 37 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 38 | if !templ_7745c5c3_IsBuffer { 39 | defer func() { 40 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 41 | if templ_7745c5c3_Err == nil { 42 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 43 | } 44 | }() 45 | } 46 | ctx = templ.InitializeContext(ctx) 47 | return nil 48 | }) 49 | templ_7745c5c3_Err = docsPageLayout(page).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 50 | if templ_7745c5c3_Err != nil { 51 | return templ_7745c5c3_Err 52 | } 53 | return nil 54 | }) 55 | } 56 | 57 | var _ = templruntime.GeneratedTemplate 58 | -------------------------------------------------------------------------------- /templates/pages/docs_emails.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | // "github.com/mikestefanello/pagoda/templates/components" 6 | templ DocumentationEmailingPage(page *controller.Page) { 7 | @docsPageLayout(page) { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/pages/docs_emails_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import "github.com/mikestefanello/pagoda/pkg/controller" 12 | 13 | // "github.com/mikestefanello/pagoda/templates/components" 14 | func DocumentationEmailingPage(page *controller.Page) templ.Component { 15 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 16 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 17 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 18 | return templ_7745c5c3_CtxErr 19 | } 20 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 21 | if !templ_7745c5c3_IsBuffer { 22 | defer func() { 23 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 24 | if templ_7745c5c3_Err == nil { 25 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 26 | } 27 | }() 28 | } 29 | ctx = templ.InitializeContext(ctx) 30 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 31 | if templ_7745c5c3_Var1 == nil { 32 | templ_7745c5c3_Var1 = templ.NopComponent 33 | } 34 | ctx = templ.ClearChildren(ctx) 35 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 36 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 37 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 38 | if !templ_7745c5c3_IsBuffer { 39 | defer func() { 40 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 41 | if templ_7745c5c3_Err == nil { 42 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 43 | } 44 | }() 45 | } 46 | ctx = templ.InitializeContext(ctx) 47 | return nil 48 | }) 49 | templ_7745c5c3_Err = docsPageLayout(page).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 50 | if templ_7745c5c3_Err != nil { 51 | return templ_7745c5c3_Err 52 | } 53 | return nil 54 | }) 55 | } 56 | 57 | var _ = templruntime.GeneratedTemplate 58 | -------------------------------------------------------------------------------- /templates/pages/error.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "fmt" 6 | "github.com/mikestefanello/pagoda/pkg/routing/routenames" 7 | ) 8 | 9 | templ Error(page *controller.Page) { 10 |
11 |
12 |
13 |

14 | { fmt.Sprintf("%d", page.StatusCode) } 15 |

16 |

17 | @title(page) 18 |

19 |

20 | @subtitle(page) 21 |

22 | Back to Homepage 31 |
32 |
33 |
34 | } 35 | 36 | templ title(page *controller.Page) { 37 | if page.StatusCode >= 500 { 38 | { "Please try again." } 39 | } else if page.StatusCode == 403 || page.StatusCode == 401 { 40 | { "You are not authorized to view the requested page." } 41 | } else if page.StatusCode == 404 { 42 | { "Something's missing." } 43 | } else { 44 | { "Something went wrong" } 45 | } 46 | } 47 | 48 | templ subtitle(page *controller.Page) { 49 | if page.StatusCode >= 500 { 50 | { "Oops! Something went wrong on our end. Please refresh the page or try again later." } 51 | } else if page.StatusCode == 403 || page.StatusCode == 401 { 52 | { "You are not authorized to view the requested page." } 53 | } else if page.StatusCode == 404 { 54 | { "Sorry, we can't find that page. You'll find lots to explore on the home page. " } 55 | } else { 56 | { "Something went wrong. Please refresh the page or try again later." } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /templates/pages/forgot_password.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/types" 6 | "github.com/mikestefanello/pagoda/templates/components" 7 | "github.com/mikestefanello/pagoda/pkg/routing/routenames" 8 | ) 9 | 10 | templ ForgotPassword(page *controller.Page) { 11 | if form, ok := page.Form.(*types.ForgotPasswordForm); ok { 12 |
17 |
18 |

Enter your email below, and if it matches an account in our system, we'll send you a reset link.

21 |
22 | 23 |
24 | 25 | 33 | @components.FormFieldErrors(form.Submission.GetFieldErrors("Email")) 34 |
35 |
36 |
37 | 41 | Cancel 45 |
46 | @components.AuthButtons(page, true, true, false) 47 | @components.FormCSRF(page.CSRF) 48 |
49 | } 50 | } 51 | -------------------------------------------------------------------------------- /templates/pages/healthcheck.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import "github.com/mikestefanello/pagoda/pkg/controller" 4 | 5 | templ HealthCheck(page *controller.Page) { 6 |
7 |

Service is up and running!

8 |
9 | } 10 | -------------------------------------------------------------------------------- /templates/pages/healthcheck_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import "github.com/mikestefanello/pagoda/pkg/controller" 12 | 13 | func HealthCheck(page *controller.Page) templ.Component { 14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 16 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 17 | return templ_7745c5c3_CtxErr 18 | } 19 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 20 | if !templ_7745c5c3_IsBuffer { 21 | defer func() { 22 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 23 | if templ_7745c5c3_Err == nil { 24 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 25 | } 26 | }() 27 | } 28 | ctx = templ.InitializeContext(ctx) 29 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 30 | if templ_7745c5c3_Var1 == nil { 31 | templ_7745c5c3_Var1 = templ.NopComponent 32 | } 33 | ctx = templ.ClearChildren(ctx) 34 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Service is up and running!

") 35 | if templ_7745c5c3_Err != nil { 36 | return templ_7745c5c3_Err 37 | } 38 | return nil 39 | }) 40 | } 41 | 42 | var _ = templruntime.GeneratedTemplate 43 | -------------------------------------------------------------------------------- /templates/pages/profile.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/types" 6 | "github.com/mikestefanello/pagoda/templates/components" 7 | ) 8 | 9 | templ ProfilePage(page *controller.Page) { 10 | if data, ok := page.Data.(types.ProfilePageData); ok { 11 | @components.PrevNavBarWithTitle(page.ToURL("convo", data.Profile.ID), "", "❤️ Your Partner") 12 | @components.Profile(page, data.Profile, data.IsSelf, false, data.UploadGalleryPicUrl, data.UploadProfilePicUrl, data.GalleryPicsMaxCount) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /templates/pages/profile_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/mikestefanello/pagoda/pkg/controller" 13 | "github.com/mikestefanello/pagoda/pkg/types" 14 | "github.com/mikestefanello/pagoda/templates/components" 15 | ) 16 | 17 | func ProfilePage(page *controller.Page) templ.Component { 18 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 19 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 20 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 21 | return templ_7745c5c3_CtxErr 22 | } 23 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 24 | if !templ_7745c5c3_IsBuffer { 25 | defer func() { 26 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 27 | if templ_7745c5c3_Err == nil { 28 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 29 | } 30 | }() 31 | } 32 | ctx = templ.InitializeContext(ctx) 33 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 34 | if templ_7745c5c3_Var1 == nil { 35 | templ_7745c5c3_Var1 = templ.NopComponent 36 | } 37 | ctx = templ.ClearChildren(ctx) 38 | if data, ok := page.Data.(types.ProfilePageData); ok { 39 | templ_7745c5c3_Err = components.PrevNavBarWithTitle(page.ToURL("convo", data.Profile.ID), "", "❤️ Your Partner").Render(ctx, templ_7745c5c3_Buffer) 40 | if templ_7745c5c3_Err != nil { 41 | return templ_7745c5c3_Err 42 | } 43 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ") 44 | if templ_7745c5c3_Err != nil { 45 | return templ_7745c5c3_Err 46 | } 47 | templ_7745c5c3_Err = components.Profile(page, data.Profile, data.IsSelf, false, data.UploadGalleryPicUrl, data.UploadProfilePicUrl, data.GalleryPicsMaxCount).Render(ctx, templ_7745c5c3_Buffer) 48 | if templ_7745c5c3_Err != nil { 49 | return templ_7745c5c3_Err 50 | } 51 | } 52 | return nil 53 | }) 54 | } 55 | 56 | var _ = templruntime.GeneratedTemplate 57 | -------------------------------------------------------------------------------- /templates/pages/reset_password.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/mikestefanello/pagoda/pkg/controller" 5 | "github.com/mikestefanello/pagoda/pkg/types" 6 | "github.com/mikestefanello/pagoda/templates/components" 7 | ) 8 | 9 | templ ResetPassword(page *controller.Page) { 10 | if form, ok := page.Form.(*types.ResetPasswordForm); ok { 11 |
17 |
18 | 19 |
20 | 28 | @components.FormFieldErrors(form.Submission.GetFieldErrors("Password")) 29 |
30 |
31 |
32 | 33 |
34 | 42 | @components.FormFieldErrors(form.Submission.GetFieldErrors("ConfirmPassword")) 43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 | @components.AuthButtons(page, true, true, false) 51 | @components.FormCSRF(page.CSRF) 52 |
53 | } 54 | } 55 | -------------------------------------------------------------------------------- /templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | type ( 4 | Page string 5 | ) 6 | 7 | const ( 8 | PageAbout Page = "about" 9 | PageLanding Page = "landing" 10 | PageContact Page = "contact" 11 | PageError Page = "error" 12 | PageForgotPassword Page = "forgot-password" 13 | PageHome Page = "home" 14 | PageLogin Page = "login" 15 | PageRegister Page = "register" 16 | PageResetPassword Page = "reset-password" 17 | PageEmailSubscribe Page = "email-subscribe" 18 | PagePreferences Page = "preferences" 19 | PagePhoneNumber Page = "preferences.phone" 20 | PageDisplayName Page = "preferences.display_name" 21 | PageHomeFeed Page = "home_feed" 22 | PageInstallApp Page = "install_app" 23 | PageProfile Page = "profile" 24 | PageNotifications Page = "notifications" 25 | PageHealthcheck Page = "healthcheck" 26 | PagePricing Page = "pricing" 27 | PageSuccessfullySubscribed Page = "successfully_subscribed" 28 | PageDeleteAccount Page = "delete_account.page" 29 | PagePrivacyPolicy Page = "privacy_policy" 30 | PageWiki Page = "wiki" 31 | 32 | SSEAnsweredByFriend Page = "sse_answered_by_friend" 33 | ) 34 | -------------------------------------------------------------------------------- /testdata/photos/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/testdata/photos/1.jpg -------------------------------------------------------------------------------- /testdata/photos/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leomorpho/goship/0df3e11a307b430f33275dc560ab3d4db782d3f9/testdata/photos/2.jpg -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", // Specify ECMAScript target version 4 | "module": "esnext", // Specify module code generation 5 | "strict": true, // Enable all strict type-checking options 6 | "esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules 7 | "skipLibCheck": true, // Skip type checking of all declaration files (*.d.ts) 8 | "forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file. 9 | "baseUrl": ".", // Base directory to resolve non-relative module names 10 | "paths": { 11 | "*": ["node_modules/*", "src/types/*"] // Specify paths for modules 12 | }, 13 | "allowJs": true, // Allow JavaScript files to be compiled. 14 | "outDir": "./dist", // Redirect output structure to the directory 15 | "rootDir": "./javascript", // Specify the root directory of input files 16 | "moduleResolution": "node", // Specify module resolution strategy 17 | "resolveJsonModule": true, // Include modules imported with '.json' extension 18 | "sourceMap": true // Generates corresponding '.map' file 19 | }, 20 | "include": ["./javascript/**/*"], // Include all files in the javascript directory 21 | "exclude": ["node_modules", "**/*.spec.ts"] // Exclude node_modules and test files 22 | } 23 | --------------------------------------------------------------------------------