Commit graph

22 commits

Author SHA1 Message Date
Paulo Castellano
1bb67b7abb Keep Instagram/Threads tokens extended while still valid
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).

Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
  OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
  valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().

Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.

Refs #126
2026-07-03 09:18:45 -03:00
Paulo Castellano
3dd1804c8e Stop publishers/analytics from rotating still-valid tokens
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.

Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.

- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
  anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
  the expired-token path are unchanged.

Refs #126
2026-07-03 08:30:58 -03:00
Paulo Castellano
9634e88e5d Add Telegram publishing (backend foundation)
Register Telegram as a platform: Platform/ContentType enum cases, a
platforms.telegram config block (shared bot token via env), TelegramPublisher
(sendMessage / sendPhoto|Video|Document / sendMediaGroup over the Bot API, HTML
parse mode, 4096 limit with long text split off a 1024 caption), wired into the
publisher dispatch. Add a Telegram ContentSanitizer branch (Telegram-allowed
HTML + ampersand escaping), MediaOptimizer/profile-url/factory support, and the
TelegramPublishException. Tests cover text, single media, album, long-text split,
overflow, API errors, private-channel URLs, and sanitization.
2026-06-13 21:25:31 -03:00
Paulo Castellano
4debc97cb0 feat(posthog): keep social_accounts_count and posts_count fresh on account group
Onboarding/lifecycle workflows in PostHog (and downstream tools like SendKit)
need to segment users by how many social accounts they've connected and how
many posts they've created. The existing SyncUser job only re-emitted these
counts on signup and billing changes, so the values went stale the moment a
user did anything meaningful.

This wires up two new paths that refresh the account group automatically:

- SocialAccountObserver (#[ObservedBy] on the model) fires SyncAccountUsage
  on created/deleted, covering all 14 OAuth callback paths in one hook.
- SyncUsageOnPostCreated / SyncUsageOnPostDeleted listeners (auto-discovered)
  fire SyncAccountUsage on the corresponding events dispatched by CreatePost
  and DeletePost.

SyncAccountUsage is the new dedicated job for group properties only
(groupIdentify account + workspace). SyncUser was slimmed to just identify
the user and delegate the group sync, removing the duplicated property
mapping between the two jobs.

All entry points (observer + both listeners) short-circuit when PostHog is
disabled, so self-hosted instances without PostHog configured see zero
queued jobs and zero overhead.

posts_count cache is invalidated before each sync so the job reads fresh
counts from the database instead of stale cached values.
2026-05-15 18:40:07 -03:00
Paulo Castellano
a37eb6ae9e fix(social): unify status lock + i18n notification strings
Two follow-up fixes from the code review:

1. **Unified lock key for markAsDisconnected / markAsTokenExpired.**
   Both methods now use `social_account_status:{id}` instead of
   different keys. Prevents the race where `markAsDisconnected` and
   `markAsTokenExpired` could run concurrently on the same account
   (publish-time vs verify-batch-time), causing overlapping updates and
   duplicate notifications.

2. **i18n for notification title/body in markAsTokenExpired and
   markAsDisconnected.** Strings were previously hardcoded in English.
   Added `notifications.account_disconnected.{title,body}` and
   `notifications.account_token_expired.{title,body}` in en, pt-BR, es.
   Follows the project convention (e.g. `Mail/PostPublished`) of
   concatenating the `@` prefix in PHP before passing the username to
   the translation placeholder, instead of putting `@:account` in the
   lang file.
2026-05-12 19:11:23 -03:00
Paulo Castellano
620d23187e fix(social): handle TokenExpired status fail-fast and notify user
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:

1. **PublishToSocialPlatform**: fail-fast when account status is
   `TokenExpired`. Previously the job tried to publish, the publisher
   internally tried to refresh, the provider rejected the rotated
   refresh_token, and the failure surfaced as a generic 'unknown' error
   instead of a clear 'reconnect your account' signal.

2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
   refresh_token (typically because it was rotated/revoked at X), log the
   raw response and throw `TokenExpiredException` instead of falling
   through to `XPublishException::fromApiResponse` which expects the
   tweet-API response shape (`type`/`title`/`detail`) and treats
   OAuth-style responses (`error`/`error_description`) as 'Unknown'.

3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
   notification (`Type::AccountDisconnected`) when an account
   transitions from `Connected` → `TokenExpired`, mirroring the
   existing pattern in `markAsDisconnected`. Wrapped in a lock to
   prevent duplicate notifications on concurrent transitions. Accepts an
   optional `notify: false` so the batch verifier
   (`VerifyWorkspaceConnections`) can suppress per-account
   notifications and rely on its summary email.
2026-05-12 18:46:06 -03:00
Paulo Castellano
595510812c refactor: implement lazy token refreshing and persist Mastodon scopes 2026-05-03 21:58:25 -03:00
Paulo Castellano
17ca1f7b0e feat: implement paginated social accounts table with search functionality and updated UI components 2026-05-02 14:15:41 -03:00
Paulo Castellano
2da15df96c feat: introduce Account entity as billing owner and refactor architecture
- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
  WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
  enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
2026-04-14 22:22:04 -03:00
Paulo Castellano
c107ce3949 feat: create brands with CRUD, policy, and tests 2026-04-14 18:04:41 -03:00
Paulo Castellano
9d0a860d87 fix: prevent double-publish race condition, media upload auth bypass, TokenExpired disconnected_at 2026-04-01 12:34:38 -03:00
Paulo Castellano
a1df51234b fix: review #5 — ContentSanitizer in all publishers, refresh lock, CheckSocialConnections includes TokenExpired 2026-04-01 12:02:15 -03:00
Paulo Castellano
a69dc28298 fix: VerifyWorkspaceConnections uses TokenExpired/Disconnected escalation, eager load workspace 2026-04-01 11:52:03 -03:00
Paulo Castellano
8ca846a865 feat: use TokenExpired state instead of Disconnected on first token failure 2026-04-01 11:46:51 -03:00
Paulo Castellano
843b3991ec chore: posthog, ui, features and more 2026-03-30 21:18:07 -03:00
Paulo Castellano
09e37f2879 feat: notification system with SendNotification job, dialog UI, tests
Backend:
- Create notifications table (user_id, workspace_id, type, channel,
  title, body, data JSON, read_at, archived_at)
- Create Notification model with Type enum (post_failed,
  account_disconnected, invite_received, member_joined, member_removed)
  and Channel enum (email, in_app, both)
- Create SendNotification job: isolated from publish flow, handles
  saving in-app notification and sending email independently
- NotificationController: index (excludes archived, scoped to workspace),
  markAsRead, markAllAsRead, archiveAll
- Integrate with PublishToSocialPlatform (post failed/partial)
- Integrate with VerifyWorkspaceConnections (batch disconnection)
- Integrate with SocialAccount::markAsDisconnected (single disconnection)
- All use SendNotification::dispatch() instead of direct Mail::to()

Frontend:
- NotificationBell component in sidebar footer with unread badge
- Dialog with notification list, mark as read, mark all read, archive all
- Click navigates to relevant page (post edit, accounts)
- i18n for notifications UI (en, es, pt-BR)

Tests:
- 8 tests for NotificationController (auth, CRUD, workspace scoping)
- 4 tests for SendNotification job (channels, email, data storage)

All 745 tests passing.
2026-03-30 16:47:03 -03:00
Paulo Castellano
56b8c92e72 refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals

Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController

Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props

All 710 tests passing.
2026-03-30 00:20:43 -03:00
Paulo Castellano
8689e54e55 refactor: restructure to Actions, subdomain routes (app/api), API tokens
- Extract business logic from controllers into Action classes:
  Post/, Workspace/, Hashtag/, Label/, Invite/, ApiKey/
- Create subdomain routing: app.trypost.test (Inertia dashboard),
  api.trypost.test (REST API with token auth)
- Add ApiToken model with tp_ prefix, token_lookup/hash auth
- Add AuthenticateApiToken middleware for API authentication
- Create Api controllers with JSON Resources for all entities
- Create App controllers that use Actions + Inertia responses
- Organize Form Requests into Api/ and App/ directories
- Add api_tokens migration
- Update all route names with app. prefix
- Update all tests to use new route names (684 passing)
2026-03-29 19:24:28 -03:00
Paulo Castellano
d39de0752c feat: adding tests.. 2026-01-18 21:49:13 -03:00
Paulo Castellano
7042bcb5ae feat: Implement soft deletes for workspace hashtags and labels, and integrate content_type into the initial post platforms table creation. 2026-01-17 14:44:37 -03:00
Paulo Castellano
7c00c3387e feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates. 2026-01-16 23:46:30 -03:00
Paulo Castellano
6890147aa6 chore: first commit 2026-01-14 22:13:44 -03:00