Potok is a Phoenix 1.8 and LiveView application for account-based collaboration around profiles, groups, invitations, and posted values. The app is server-rendered, locale-aware, and organized around two states after login: profile selection and profile-scoped group activity. It also exposes browser-side Potok APIs through LiveView hooks, providing a foundation for Potok-scoped application development on top of group descriptions and values.
The main concepts in the system are:
Account: the authenticated identity. An account has an email, optional password login, confirmation state, many profiles, one selectedcurrent_profile, and onedefault_profileused as the fallback profile when no explicit current profile is set.Profile: the social identity used inside groups. A profile can beuniqueorshared, can be linked to one or more accounts depending on sharing rules, and can create groups and values.Group: a hierarchical collaboration container with a creator profile, memberships, child groups, optional parent relations, and a persistedhome_pagepreference that controls which panel opens by default.Value: content posted by a profile in a group, optionally as part of a reply chain.GroupMembership: the join entity between profiles and groups.GroupInvitation: an invitation from one profile to another profile to join a group.GroupJoinRequest: a pending request from a non-member profile to join a public group, reviewed by the group creator.AccountProfile: the join entity between accounts and profiles.
Invitation behavior:
- Invitations are stored against the invitee profile, not against a specific account.
- If the invitee profile is shared, the pending invitation is effectively shared as well, because all linked accounts access the same profile record.
- Accepting an invitation adds the profile to the target group, so the resulting membership is also shared by every account linked to that profile.
Group access and direct-group behavior:
- Public groups support access requests from non-member profiles. Group creators review those pending requests from the members tab and can accept or reject them there.
- Groups persist a
home_pageenum with the valuesdescription,chat, andsubgroups. The create-group and edit-group panels expose that setting so members can control the default landing panel for a group. - Opening a group without an explicit tab uses its
home_page:descriptionopens the description panel,chatopens the values panel, andsubgroupsopens the sub-groups panel. - Root groups ignore
home_pageand always open on the sub-groups panel. - Direct groups are derived conversation groups, not a separate invitation flow. Opening
/profiles/:id/directfinds or creates a private non-root group under the root group for exactly two profiles and then navigates to that group's values tab. - Direct private groups ignore
home_pageand always open on the values panel. - Because direct groups are fixed to two members, the group UI hides profile-invite actions, join-request management, and member removal controls for them.
Relationship summary:
Account 1----* AccountProfile *----1 Profile
Account 1----0..1 current_profile -> Profile
Account 1----0..1 default_profile -> Profile
Profile 1----* GroupMembership *----1 Group
Profile 1----* created_groups
Profile 1----* values
Group 1----* child_groups
Group 1----* values
Group 0..1----1 parent_group
Group 0..1----1 parent_value
Value 0..1----1 parent_value
Value 1----* child_values
GroupInvitation belongs_to group, inviter(profile), invitee(profile)
GroupJoinRequest belongs_to group, requester(profile)
Default profile behavior:
- The first profile created for an account becomes both its
current_profileanddefault_profile. - Later profile creation does not overwrite either selection.
- If
current_profileis cleared, the web layer falls back todefault_profilefor profile-scoped behavior.
Avatar behavior:
- Profile avatars are resolved from
profile_picture_urlonly when it is a non-empty string. - When a profile has no avatar URL (or an empty URL), the app uses
GET /avatar/profile/:idto render initials. - The nav bar updates this avatar source during profile switches through the
phx:current_profile_updatedevent so changing from a profile with a URL to one without a URL immediately falls back to/avatar/profile/:id. - Push-notification avatar fields follow the same fallback route strategy and are converted to absolute URLs before sending FCM payloads.
Prerequisites:
- Elixir
~> 1.15as declared inmix.exs - Erlang/OTP compatible with your Elixir installation
- PostgreSQL running locally
- Node.js and npm (required because
mix assets.setuprunsnpm installinassets/)
For the smoothest release parity, use an Elixir and OTP pair that is compatible with the included Docker build, which currently uses Elixir 1.18.2 and OTP 27.3.4.
Recommended local versions:
- Node.js
20+ - npm
10+
Default local database settings:
- username:
postgres - password:
postgres - hostname:
localhost - development database:
potok_ide_dev - test database:
potok_ide_test
If your local PostgreSQL setup differs, update config/dev.exs and config/test.exs before running setup.
- Install dependencies, create the database, run migrations, and build assets:
mix setupThe first run may take longer because mix assets.setup installs npm dependencies in assets/.
- Start the application:
mix phx.server- Open
http://localhost:4000.
mix phx.server starts the Phoenix endpoint together with the configured Tailwind and esbuild watchers from config/dev.exs.
The seed script currently only contains the default template comments, so a fresh setup gives you schema only.
mix testruns the test suite.mix ecto.resetdrops and recreates the local database.mix run priv/repo/seeds.exsruns the seed script manually.mix assets.setupinstalls the pinned Tailwind and esbuild binaries used by the project.mix assets.buildrebuilds Tailwind and esbuild assets.mix precommitruns the main verification alias: compile with warnings as errors, unlock unused deps, format, and test.
Potok now includes Capacitor wrappers in assets/ for both Android and iOS.
Important architecture note:
- Potok is a Phoenix LiveView application, so the Capacitor shell does not run a standalone static build of the app.
- The native WebView loads a running Phoenix server through Capacitor's
server.urlsetting. - By default the Capacitor config uses
http://10.0.2.2:4000when syncing Android andhttp://localhost:4000when syncing iOS. - Use
CAPACITOR_SERVER_URLfor a shared override, orCAPACITOR_ANDROID_SERVER_URLandCAPACITOR_IOS_SERVER_URLfor platform-specific overrides. - Magic-link emails now include a standard HTTPS deep link at
https://potok.rs/accounts/log-in/<token>?app=1, which is used for both Android App Links and iOS Universal Links.
Files and commands:
- Capacitor config:
assets/capacitor.config.ts - Android project:
assets/android - iOS project:
assets/ios - Sync native project after config changes:
cd assets && npm run cap:sync:android - Sync iOS project after config changes:
cd assets && npm run cap:sync:ios - Open Android Studio:
cd assets && npm run cap:open:android - Open the iOS project in Xcode:
cd assets && npm run cap:open:ios - Run on a connected emulator or device:
cd assets && npm run cap:run:android - Run on an iOS simulator or device from macOS:
cd assets && npm run cap:run:ios
Development workflow:
- Start Phoenix locally with
mix phx.server. - In another shell, set
CAPACITOR_SERVER_URLif you are not using the platform default. - From
assets/, runnpm run cap:sync:android. - Open or run the Android app with one of the Capacitor scripts above.
Examples:
# Android emulator against local Phoenix server
Set-Location assets
$env:CAPACITOR_SERVER_URL = "http://10.0.2.2:4000"
npm run cap:sync:android
npm run cap:open:android# Physical device on the same LAN
$env:PHX_DEV_BIND_ALL = "true"
mix phx.server
Set-Location assets
$env:CAPACITOR_SERVER_URL = "http://192.168.1.50:4000"
npm run cap:sync:android
npm run cap:run:androidFor production, point CAPACITOR_SERVER_URL at your deployed HTTPS endpoint before syncing.
Example iOS simulator flow on macOS:
cd assets
CAPACITOR_SERVER_URL=http://localhost:4000 npm run cap:sync:ios
CAPACITOR_SERVER_URL=http://localhost:4000 npm run cap:open:iosMagic-link login in the native apps:
- Start the Capacitor app against the same backend you want to use for login.
- Request a magic link from the login screen in the app.
- In the email, tap the
https://potok.rs/accounts/log-in/...?...app link to reopen the Potok app. - The app will navigate its WebView to the existing
/accounts/log-in/:tokenscreen on your configured backend. - Confirm the login on that screen so the Phoenix session cookie is created inside the app WebView.
Older potok://login/... links are still handled by the app for backward compatibility, but new emails use standard HTTPS deep links so they are clickable in mail clients and can be claimed by both Android and iOS.
iOS Universal Links notes:
- the app serves
/.well-known/apple-app-site-associationand/apple-app-site-association - the generated iOS app enables the
applinks:potok.rsassociated domain inassets/ios/App/App/App.entitlements - set
IOS_APP_LINK_TEAM_IDin production so the association file can publish the real Apple app identifier - if you need to override the generated identifier directly, set
IOS_APP_LINK_APP_ID; otherwise the server composes it fromIOS_APP_LINK_TEAM_IDandIOS_APP_LINK_BUNDLE_ID
Potok now includes Web Push subscription support for the installable PWA.
Required environment variables:
WEB_PUSH_VAPID_SUBJECTWEB_PUSH_VAPID_PUBLIC_KEYWEB_PUSH_VAPID_PRIVATE_KEY
You can generate a VAPID keypair with:
mix potok.gen.vapid_keypairThe same account settings screen at /accounts/settings now also manages native push notifications for the installed Android app.
Android setup requirements:
- install the Capacitor push plugin in
assets/withnpm install @capacitor/push-notifications - place your Firebase
google-services.jsonfile inassets/android/app/google-services.json - run
cd assets && npm run cap:sync:androidafter changing Capacitor or Android notification config
Server-side Firebase configuration:
- either set
FIREBASE_SERVICE_ACCOUNT_JSONto the full service-account JSON document - or set all of
FIREBASE_PROJECT_ID,FIREBASE_CLIENT_EMAIL, andFIREBASE_PRIVATE_KEY - optionally set
FIREBASE_TOKEN_URLif you need a non-default Google OAuth token endpoint - optionally set
FIREBASE_CHANNEL_IDto override the default Android notification channel id (potok-default)
Once the relevant browser or Firebase credentials are configured, /accounts/settings exposes the shared enable, disable, and test notification controls for both the PWA and the installed Android app.
The Android project is now set up so assembleRelease produces a signed APK when the signing values are provided in assets/android/release-signing.properties, as Gradle properties, or as environment variables.
Required signing keys:
POTOK_UPLOAD_STORE_FILEPOTOK_UPLOAD_STORE_PASSWORDPOTOK_UPLOAD_KEY_ALIASPOTOK_UPLOAD_KEY_PASSWORD
Recommended setup:
- Copy
assets/android/release-signing.properties.exampletoassets/android/release-signing.properties. - Fill in your keystore path, alias, and passwords there.
The real release-signing.properties file is gitignored, so you do not need to put signing secrets in shell history.
Example PowerShell flow:
Set-Location assets
$env:CAPACITOR_SERVER_URL = "https://potok.rs"
npm run cap:sync:android
Set-Location android
.\gradlew.bat assembleReleaseThe signed APK will be written to assets/android/app/build/outputs/apk/release/.
If any of the signing values are missing, Gradle now fails release builds with a clear error instead of silently producing an unsigned release artifact.
The Android project can also load debug signing values from assets/android/debug-signing.properties.
Required debug signing keys:
POTOK_DEBUG_STORE_FILEPOTOK_DEBUG_STORE_PASSWORDPOTOK_DEBUG_KEY_ALIASPOTOK_DEBUG_KEY_PASSWORD
Recommended setup:
- Copy
assets/android/debug-signing.properties.exampletoassets/android/debug-signing.properties. - Fill in the debug keystore path, alias, and passwords there.
If debug-signing.properties is absent, Android Gradle Plugin falls back to its normal default debug keystore behavior. If the file is present but incomplete, debug builds fail with a clear error.
The default development configuration includes:
- local email delivery through
Swoosh.Adapters.Local - mailbox preview at
http://localhost:4000/dev/mailbox - LiveDashboard at
http://localhost:4000/dev/dashboard - live asset rebuilding through Phoenix endpoint watchers
Production mail delivery is configured at runtime through MAILER_ADAPTER.
If MAILER_ADAPTER is omitted, the app defaults to mailgun.
For Gmail API delivery:
- set
MAILER_ADAPTER=gmail - set
MAILER_FROM_EMAIL - optionally set
MAILER_FROM_NAME(defaults toPotok) - either set
GMAIL_API_ACCESS_TOKEN - or set both
GMAIL_CLIENT_IDandGMAIL_CLIENT_SECRET - optionally set
GMAIL_REFRESH_TOKENwhen needed for initial token bootstrap
If you use the refresh token flow, the application exchanges the refresh token for a short-lived access token on each email send through Google's OAuth token endpoint. You can override that endpoint with GMAIL_TOKEN_URL if needed.
Mailgun remains available by setting MAILER_ADAPTER=mailgun together with MAILGUN_API_KEY and MAILGUN_DOMAIN.
The entry flow at / is auth-aware:
- Guests are redirected to
/accounts/log-in. - Authenticated accounts without a current or default profile are redirected to
/profiles. - Authenticated accounts with either a current profile or a default profile are redirected to
/groups.
The web layer combines three cross-cutting concerns:
PotokIdeWeb.AccountAuthfor authenticated account loading across controllers and LiveViewsPotokIdeWeb.ProfileAuthfor loading and enforcing a current profile when profile-scoped features are usedPotokIdeWeb.Localefor locale resolution from params, session, and request headers in both Plug and LiveView lifecycles
Profiles also have a sharing mode:
uniqueprofiles are meant to stay attached to one accountsharedprofiles can be linked to multiple accounts throughAccountProfile
Group invitations are addressed to profiles, not directly to accounts. That means a shared profile carries its memberships and pending invitations with it, and any linked account can see them when that shared profile is selected as the current profile.
Backend and framework:
Phoenix 1.8.3Phoenix LiveView 1.1Ectoandecto_sqlPostgrexBandit
Authentication, email, and HTTP:
pbkdf2_elixirSwooshReq
Frontend and assets:
Tailwind CSS 4.1.12daisyUItheme plugins loaded throughassets/css/app.cssesbuild 0.25.4Heroicons
Localization and observability:
Gettexttelemetry_metricstelemetry_poller
Testing and DX:
ExUnitLazyHTML- Mix aliases for setup, asset builds, and verification
The router is organized by authentication and profile requirements.
Public browser routes:
GET /redirects based on account and profile stateGET /locale/:localeupdates the active localeGET /accounts/log-inGET /accounts/log-in/:tokenPOST /accounts/log-inDELETE /accounts/log-out
Authenticated routes:
GET /profilesfor profile selection and setupGET /profiles/newGET /profiles/:id/editGET /accounts/settingsGET /accounts/settings/confirm-email/:tokenPOST /accounts/update-passwordPOST /accounts/push-subscriptionsDELETE /accounts/push-subscriptionsPOST /accounts/push-subscriptions/test
Authenticated routes that require a current profile:
GET /profiles/:id/directGET /groupsGET /groups/:idGET /groups/:id/:tabGET /invitationsGET /requestsGET /accounts/register
Development-only routes when dev_routes is enabled:
GET /dev/dashboardGET /dev/mailbox
The router uses separate LiveView sessions for:
- auth-aware public pages (
:current_account) - authenticated account settings (
:require_authenticated_account) - authenticated profile selection (
:authenticated) - profile-required collaboration screens (
:profile_required)
The group description panel exposes a browser API through the GroupDescriptionActions LiveView hook in assets/js/app.js.
The API is attached to the hooked DOM element as element.potok while the panel is mounted.
Available functions:
insertGroupValue(content, options)pushes acreate_data_valueevent and returns the server reply.contentis normalized to a string.options.contentFormat/options.content_formatcontrolsvalue.content_format(default:"markdown").
updateGroupDescription(description, options)pushes anupdate_group_descriptionevent and returns the server reply.descriptionis normalized to a string.options.descriptionFormat/options.description_formatcontrolsdescription_format.- If no format is provided, the hook uses
data-description-formatfrom the element and falls back to"html".
getGroupDescription()returns the current raw group description and format from hook dataset attributes.getGroupDataValues()pushes alist_group_data_valuesevent and returns the server reply.getCurrentProfile()returns the parsed JSON object fromdata-current-profile, ornullif missing/invalid.setNewValueCallback(fn)registers a callback invoked when the browser receives aphx:new_data_valueevent.
getGroupDescription() resolves to:
{
description: "...",
descriptionFormat: "html"
}The application currently ships with:
enfor Englishplfor Polishsrfor Serbian
Translations live under priv/gettext/. Locale switching is exposed through the UI and backed by PotokIdeWeb.Locale in both Plug and LiveView flows.
High-level layout:
.
|- assets/
| |- css/ # Tailwind CSS entrypoint
| |- js/ # Phoenix / LiveView JavaScript entrypoint
| |- vendor/ # Bundled frontend vendor files
|- config/ # Environment-specific configuration
|- lib/
| |- potok_ide/ # Domain contexts, schemas, repo, mailer, release helpers
| | |- accounts/ # Account schema and auth-related domain code
| | |- social/ # Profile, group, value, membership, invitation schemas
| | |- accounts.ex # Account context
| | |- social.ex # Social context
| |- potok_ide_web/ # Web interface, routing, LiveViews, components
| | |- components/ # Shared UI components and layouts
| | |- controllers/ # Controller endpoints and session actions
| | |- live/ # LiveViews for auth, profiles, groups, invitations
| | |- account_auth.ex # Account auth helpers for Plug and LiveView
| | |- profile_auth.ex # Current profile loading and enforcement
| | |- locale.ex # Locale resolution and LiveView mounting
| | |- router.ex # Route and live_session composition
|- priv/
| |- gettext/ # Translation catalogs
| |- repo/ # Migrations and seeds
| |- static/ # Compiled static assets
|- rel/ # Release overlays and startup scripts
|- test/ # ExUnit tests for contexts and web layer
|- Dockerfile # Multi-stage production image build
|- fly.toml # Fly.io deployment configuration
|- mix.exs # Dependencies, aliases, and project config
The repository includes a multi-stage Dockerfile that builds a production release and starts it with /app/bin/server.
The release image currently builds with:
- Elixir
1.18.2 - Erlang/OTP
27.3.4 - Debian
trixie-20260223-slim
Build the image:
docker build -t potok-ide .Run the release container by providing the required runtime configuration:
docker run --rm -p 4000:4000 \
-e PHX_SERVER=true \
-e PHX_HOST=localhost \
-e PORT=4000 \
-e SECRET_KEY_BASE=your-secret \
-e DATABASE_URL=ecto://USER:PASS@HOST/DATABASE \
-e MAILGUN_API_KEY=your-key \
-e MAILGUN_DOMAIN=mg.example.com \
-e MAILER_FROM_EMAIL=no-reply@mg.example.com \
potok-ideNotes:
- The Docker image is production-oriented. It does not provide code reloading or a local dev shell workflow.
- Your
DATABASE_URLmust point to a database reachable from inside the container. - Asset compilation happens during image build through
mix assets.deploy. - Runtime avatar generation relies on ImageMagick SVG rendering support (for example
imagemagick+librsvg2-binon Debian-based images).
Build a release locally:
set MIX_ENV=prod
mix releaseStart it with the required runtime variables available:
set PHX_SERVER=true
_build/prod/rel/potok_ide/bin/serverOn Unix-like shells, the equivalent is:
MIX_ENV=prod mix release
PHX_SERVER=true _build/prod/rel/potok_ide/bin/serverProduction configuration is loaded from runtime environment variables.
Required in all production setups:
DATABASE_URLSECRET_KEY_BASEMAILER_FROM_EMAIL
Required when using Mailgun (MAILER_ADAPTER=mailgun, default):
MAILGUN_API_KEYMAILGUN_DOMAIN
Required when using Gmail (MAILER_ADAPTER=gmail):
- either
GMAIL_API_ACCESS_TOKEN - or both
GMAIL_CLIENT_IDandGMAIL_CLIENT_SECRET
Optional environment variables:
MAILER_ADAPTERdefaults tomailgunPHX_HOSTdefaults toexample.comPORTdefaults to4000POOL_SIZEdefaults to10ECTO_IPV6enables IPv6 socket options when set totrueor1DNS_CLUSTER_QUERYconfigures distributed node discoveryMAILER_FROM_NAMEdefaults toPotokMAILGUN_BASE_URLcan be set tohttps://api.eu.mailgun.net/v3for EU Mailgun accountsGMAIL_TOKEN_URLoverrides the Gmail OAuth token endpointGMAIL_REFRESH_TOKENcan be provided for initial Gmail token bootstrapPHX_SERVER=trueenables the web server for releases if your runtime does not already set it
Operational notes:
PHX_HOSTis used to build endpoint URLs in production.PORTis read in all environments, not just production.MAILGUN_BASE_URLis useful for EU-region Mailgun accounts.DNS_CLUSTER_QUERYis only needed when you want DNS-based node discovery across multiple running nodes.
The repository already contains fly.toml with these defaults:
- app name:
potok-ide - primary region:
fra - runtime host:
potok.rs - internal service port:
8080 - release command:
/app/bin/migrate - preconfigured runtime env:
PHX_HOST=potok.rs,PORT=8080,MAILER_FROM_NAME=Potok
Example Fly.io secrets setup:
fly secrets set SECRET_KEY_BASE=your-secret
fly secrets set DATABASE_URL=ecto://USER:PASS@HOST/DATABASE
fly secrets set MAILGUN_API_KEY=your-key MAILGUN_DOMAIN=mg.example.com MAILER_FROM_EMAIL=no-reply@mg.example.com
fly secrets set MAILER_FROM_NAME="Potok"
fly secrets set ANDROID_APP_LINK_SHA256_CERT_FINGERPRINTS="your-signing-cert-sha256"
fly secrets set IOS_APP_LINK_TEAM_ID="ABCDE12345" IOS_APP_LINK_BUNDLE_ID="com.potok.ide"For Android App Links, ANDROID_APP_LINK_SHA256_CERT_FINGERPRINTS should contain one or more comma-separated SHA-256 certificate fingerprints for the APK signing keys that should be allowed to open https://potok.rs/accounts/log-in/... inside the app. Use your debug key for npm run cap:run:android testing and add your release key fingerprint before shipping a signed release.
For iOS Universal Links, IOS_APP_LINK_TEAM_ID should be your Apple Developer Team ID and IOS_APP_LINK_BUNDLE_ID should match the bundle identifier used by the signed iOS app. If you prefer to set the full value yourself, use IOS_APP_LINK_APP_ID with the TEAMID.bundle.id format.
Deploy with:
fly deployfly.toml already enables HTTPS, exposes the app on port 8080, and runs migrations during deployment.
The repository now includes .github/workflows/ios-build-publish.yml, which runs on GitHub-hosted macOS runners and performs these steps:
- installs npm dependencies in
assets/ - syncs the Capacitor iOS project with
npm run cap:sync:ios - imports an Apple distribution certificate and provisioning profile
- archives the Xcode project and exports an IPA
- uploads the IPA as a GitHub artifact
- optionally uploads the IPA to App Store Connect / TestFlight
Required GitHub Actions secrets:
CAPACITOR_SERVER_URLIOS_BUILD_CERTIFICATE_BASE64IOS_BUILD_CERTIFICATE_PASSWORDIOS_BUILD_KEYCHAIN_PASSWORDIOS_BUILD_PROVISION_PROFILE_BASE64IOS_BUILD_PROVISION_PROFILE_NAMEIOS_DEVELOPMENT_TEAMIOS_BUNDLE_IDENTIFIERAPP_STORE_CONNECT_KEY_IDAPP_STORE_CONNECT_ISSUER_IDAPP_STORE_CONNECT_PRIVATE_KEY_BASE64
Trigger the workflow manually from the Actions tab. Set upload_to_testflight to false if you only want a signed artifact without publishing it.
Use mix precommit as the final verification step for code changes. If you change the domain model, routes, or deployment assumptions, update this README at the same time so it stays aligned with the application.