diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 269363033c..ff9319a8ad 100644 --- a/freescout.wordpress.net/AGENTS.md +++ b/freescout.wordpress.net/AGENTS.md @@ -14,7 +14,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco - `WPOrgPluginReview` — the latest plugin review of a conversation in its sidebar, for the plugins team: the details, flags, and issues of its review emails' Review ID lines. It indexes which replies are review emails, and of what type; `php artisan wporgpluginreview:index` indexes them again. Only in the plugins team's mailbox, and, with the Teams module, only for members of the teams whose names start with "Plugin". Issue names and replies to copy are in its `Services/IssueNames.php` and `Services/FlagReplies.php`. The index keeps each review email's plugin ID too, which the API's conversation list takes as `reviewPluginId`. - `WPOrgSearch` — gives searches without other filters core's After filter, a year ago unless the agent picks a date, and runs core's conversation search through its `search.conversations.perform` hook in one query with `COUNT(*) OVER()`, stopped after 20 seconds on MariaDB. - `WPOrgSidebar` — WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`. - - `WPOrgSite` — tweaks for how WordPress.org runs FreeScout, rather than features; add new ones here instead of starting a module. So far: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`). + - `WPOrgSite` — tweaks for how WordPress.org runs FreeScout, rather than features; add new ones here instead of starting a module. So far: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, redirects for HelpScout's conversation links, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`). - `WPOrgSSO` — logs agents in through login.wordpress.org's SAML identity provider (wp-saml-idp, in the private dotorg repository), and connects every user to a WordPress.org account. Its SAML library is committed in its `vendor/`: FreeScout doesn't install module dependencies. After changing its `composer.json`, run `composer install --no-dev` in the module and commit `vendor/` (`git add -f`: the root `.gitignore` ignores it). - `WPOrgWebhooks` — queues conversation events to `api.wordpress.org/dotorg/freescout/webhook.php`, which records contributor stats, and keeps the copy of conversations (`wporg_helpscout`) the plugin directory reads. - `tests/` — shared PHPUnit bootstrap and base `TestCase`. diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index 8b421e41fd..f4895b9d0a 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -68,6 +68,6 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's HelpScout conversations that were never imported, or never replaced, like those HelpScout deleted, or those deleted for good in FreeScout before the switch, keep HelpScout's copies, which have no `helpdesk` meta. Clean those up in WordPress.org's copy before HelpScout closes. - Saved replies, tags, and custom fields: switch on the Saved Replies, Tags, and Custom Fields modules before importing. - Workflows: before importing, give every automatic workflow with **Apply to previous conversations** or a date condition the condition **Imported** is **No**, unless it's meant for HelpScout's history too, and the same to those added later. It goes by the conversation's latest thread, so the workflow applies to a conversation again once agents add a reply, note, or change in FreeScout. Or keep the Workflows module off until after the last import, and add the condition before switching it on. -- After the last mailbox has moved, switch the module off, and remove its code in a later deploy. Imported conversations keep HelpScout's numbers. Its tables map HelpScout's conversation IDs to FreeScout's: convert anything that still links to HelpScout before removing it. +- After the last mailbox has moved, switch the module off, and remove its code in a later deploy. Imported conversations keep HelpScout's numbers. WPOrgSite keeps its own copy of the HelpScout IDs, so HelpScout's links keep working once these tables are gone. To try it locally, start the environment with `WPORG_HELPSCOUT_APP_ID` and `WPORG_HELPSCOUT_APP_SECRET` set. The import only reads from HelpScout. diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php b/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php new file mode 100644 index 0000000000..22cf296f35 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php @@ -0,0 +1,50 @@ +unsignedBigInteger( 'helpscout_id' )->primary(); + $table->unsignedInteger( 'conversation_id' )->index(); + } + ); + + // Later imports are recorded as the importer saves them. + HelpScoutLinks::copy_imported(); + } + + /** + * Reverts the migration. + * + * @return void + */ + public function down(): void { + Schema::dropIfExists( HelpScoutLinks::TABLE ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/Controllers/HelpScoutLinksController.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/Controllers/HelpScoutLinksController.php new file mode 100644 index 0000000000..e3b1b10a72 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/Controllers/HelpScoutLinksController.php @@ -0,0 +1,39 @@ +user(); + $conversation = $user instanceof User ? HelpScoutLinks::conversation( (int) $helpscout_id, $user ) : null; + if ( ! $conversation ) { + abort( 404 ); + } + + return redirect()->away( $conversation->url() ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php new file mode 100644 index 0000000000..9792fcf605 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php @@ -0,0 +1,51 @@ +route(); + $user = $request->user(); + + if ( 404 !== $response->getStatusCode() || ! $route || 'conversations.view' !== $route->getName() || ! $user instanceof User ) { + return $response; + } + + try { + $id = (int) $route->parameter( 'id' ); + $conversation = $id ? HelpScoutLinks::conversation( $id, $user ) : null; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgSite] Could not look up a HelpScout conversation: ' . $e->getMessage() ); + $conversation = null; + } + + return $conversation ? redirect()->away( $conversation->url() ) : $response; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php new file mode 100644 index 0000000000..323cdc256a --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php @@ -0,0 +1,27 @@ + array( 'web', 'auth' ), + 'prefix' => \Helper::getSubdirectory(), + 'namespace' => 'Modules\WPOrgSite\Http\Controllers', + ), + static function (): void { + // `/conversation/{ID}` alone is core's route, handled by the RedirectHelpScoutLinks middleware. + Route::get( '/conversation/{helpscout_id}/{number}', 'HelpScoutLinksController@show' ) + ->where( + array( + 'helpscout_id' => '[0-9]+', + 'number' => '[0-9]+', + ) + ) + ->name( 'wporgsite.helpscout_link' ); + } +); diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php index 9a992c7846..401da11a87 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php @@ -12,12 +12,15 @@ use App\Conversation; use App\Thread; use App\User; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider; use Illuminate\View\View; use Modules\WPOrgSite\Http\Middleware\LockModules; +use Modules\WPOrgSite\Http\Middleware\RedirectHelpScoutLinks; use Modules\WPOrgSite\Services\AltConversation; use Modules\WPOrgSite\Services\EnvSettings; +use Modules\WPOrgSite\Services\HelpScoutLinks; use Modules\WPOrgSite\Services\PinnedNote; use Modules\WPOrgSite\Services\UserChoice; @@ -64,7 +67,10 @@ final class WPOrgSiteServiceProvider extends ServiceProvider { */ public function boot(): void { $this->app['router']->pushMiddlewareToGroup( 'web', LockModules::class ); + $this->app['router']->pushMiddlewareToGroup( 'web', RedirectHelpScoutLinks::class ); $this->loadViewsFrom( __DIR__ . '/../Resources/views', self::ALIAS ); + $this->loadRoutesFrom( __DIR__ . '/../Http/routes.php' ); + $this->loadMigrationsFrom( __DIR__ . '/../Database/Migrations' ); // Modules come with deploys, so the Modules page only lists installed ones; site.css hides the empty directory. \View::composer( @@ -126,6 +132,7 @@ static function (): void { self::register_profile_hooks(); self::register_settings_hooks(); self::register_pinned_note_hooks(); + self::register_helpscout_link_hooks(); // For alt-conversation.js, which lays the conversation out. \Eventy::addAction( @@ -344,6 +351,28 @@ static function ( mixed $thread = null ): void { ); } + /** + * Records the HelpScout ID of each conversation the importer imports, for HelpScout's links. + * + * The importer's model is named rather than used: the importer goes once every mailbox has moved. + * + * @return void + */ + private static function register_helpscout_link_hooks(): void { + \Event::listen( + 'eloquent.saved: ' . HelpScoutLinks::IMPORTER_MODEL, + static function ( mixed $imported = null ): void { + try { + if ( $imported instanceof Model ) { + HelpScoutLinks::record( (int) $imported->getAttribute( 'helpscout_id' ), (int) $imported->getAttribute( 'conversation_id' ) ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgSite] Could not record the HelpScout ID of an imported conversation: ' . $e->getMessage() ); + } + } + ); + } + /** * The copy of a note above its conversation's threads: its body as core shows it in the thread, with modules' * changes to it, and links to its attachments. diff --git a/freescout.wordpress.net/Modules/WPOrgSite/README.md b/freescout.wordpress.net/Modules/WPOrgSite/README.md index 5495195375..ca3553d2f9 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/README.md +++ b/freescout.wordpress.net/Modules/WPOrgSite/README.md @@ -7,6 +7,7 @@ Adapts FreeScout to how WordPress.org runs it. Small changes to FreeScout's own - **Modules page:** only installed modules are listed, without FreeScout's Modules Directory and Marketplace. They can't be updated or deleted there; that happens on the server. Switching them on and off still works. - **Modules page layout:** the module cards are laid out in columns, as many as the window fits. - **Settings kept in `.env`:** FreeScout saves some of Manage » Settings to `.env`, like the time zone, the language, conversation numbers, and the fetching interval, and so do modules for some of theirs. The server's `.env` is read-only, so saving one failed, and with it the rest of its section. They show read-only, with their value and a note to ask the systems team, and saving a section leaves them out, so the rest of it saves. It's in `Services/EnvSettings.php` and `Public/js/settings.js`. +- **HelpScout's links:** [a HelpScout link with FreeScout's address opens the conversation it was imported as](#helpscouts-links). - **Branding:** the WordPress mark in the top left and, on a blue tile, as the favicon. The login page keeps FreeScout's logo, so it doesn't pass for a WordPress.org login, redrawn as an SVG so it's sharp on high-resolution screens. - **Saved replies:** [chosen from the keyboard](#saved-replies-from-the-keyboard), with `/` in the editor. - **Discarding a reply or note:** an empty one, without text, images, or attachments, is discarded at once, without asking. Otherwise, core's "Discard this draft?" dialog opens next to the discard button, rather than at the top of the window, a long way from it; so it does for the drafts' Discard links. For an empty one, the dialog is answered without showing, so core discards it as it would. **Escape** discards it too, as its button does, from the editor or with nothing in particular focused, not from the form's other fields, like the recipients': in the dialog, Yes has the focus, so Enter confirms, and Escape again cancels, back to where the caret was. Escape that closes something first, like the Saved Replies menu, only closes that. It's in `Public/js/discard.js`. @@ -16,6 +17,15 @@ Adapts FreeScout to how WordPress.org runs it. Small changes to FreeScout's own - **Alternate conversation design:** agents who choose so in their profile get [conversations laid out otherwise](#alternate-conversation-design), oldest email first, with Reply and Note at the bottom. - **Conversations:** [keyboard shortcuts](#keyboard-shortcuts); the status and assignee selectors pinned to the top of the window while their menu is open, further down long conversations where the toolbar has scrolled out of view, so they can be chosen there, like after pressing `s` or `a`; the subject at 18px and the threads' authors at 16px, rather than core's 23px and 18px, so more of the conversation fits; and clicking the number next to the subject copies it, without the `#`. Its tooltip says when it's copied; where the browser doesn't let the page write to the clipboard, it copies through a selected field instead. It's all in `Public/js/conversation.js`. +## HelpScout's links + +HelpScout's links, `https://secure.helpscout.net/conversation/{ID}/{number}/`, are all over Slack, Trac, and docs. Imported conversations keep HelpScout's number, but not its ID. With FreeScout's address in front, with or without the number, such a link redirects to the conversation it was imported as. + +- **The IDs** are in the `wporgsite_helpscout_conversations` table, so they outlive the importer. Its migration copies the importer's table, and later imports are added through the `eloquent.saved` event of the importer's model. It's in `Services/HelpScoutLinks.php`. +- **`/conversation/{ID}/{number}`** is this module's route, in `Http/Controllers/HelpScoutLinksController.php`; the number is ignored. +- **`/conversation/{ID}`** is core's route. Where core answers with a 404, `Http/Middleware/RedirectHelpScoutLinks.php` redirects instead, so FreeScout's own conversations are untouched. +- **Agents** are redirected once logged in, and only to a conversation they may see; otherwise, it's a 404. + ## Keyboard shortcuts In a conversation, while the agent isn't typing in a field or the editor, and no modal is open: diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php b/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php new file mode 100644 index 0000000000..cd0aa4692f --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php @@ -0,0 +1,82 @@ +updateOrInsert( array( 'helpscout_id' => $helpscout_id ), array( 'conversation_id' => $conversation_id ) ); + } + + /** + * Copies the IDs the importer has recorded so far into the new table. + * + * @return void + */ + public static function copy_imported(): void { + if ( ! Schema::hasTable( self::IMPORTER_TABLE ) ) { + return; + } + + $grammar = \DB::getQueryGrammar(); + \DB::statement( 'INSERT INTO ' . $grammar->wrapTable( self::TABLE ) . ' (helpscout_id, conversation_id) SELECT helpscout_id, conversation_id FROM ' . $grammar->wrapTable( self::IMPORTER_TABLE ) ); + } + + /** + * The conversation a HelpScout conversation was imported as, if the agent may see it. + * + * @param int $helpscout_id HelpScout's conversation ID. + * @param User $user Agent. + * @return Conversation|null The conversation, or null if there's none they may see. + */ + public static function conversation( int $helpscout_id, User $user ): ?Conversation { + $conversation_id = \DB::table( self::TABLE )->where( 'helpscout_id', $helpscout_id )->value( 'conversation_id' ); + $conversation = $conversation_id ? Conversation::find( (int) $conversation_id ) : null; + + return $conversation instanceof Conversation && $user->can( 'view', $conversation ) ? $conversation : null; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/module.json b/freescout.wordpress.net/Modules/WPOrgSite/module.json index 5597cb7209..a4923ea054 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/module.json +++ b/freescout.wordpress.net/Modules/WPOrgSite/module.json @@ -3,7 +3,7 @@ "alias": "wporgsite", "img": "/modules/wporgsite/img/icon.svg", "description": "Adapts FreeScout to how WordPress.org runs it, e.g. modules come from deploys, not from updates here.", - "version": "1.1.0", + "version": "1.2.0", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgSite", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", diff --git a/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php new file mode 100644 index 0000000000..b28a0a4a10 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php @@ -0,0 +1,143 @@ +app->register( WPOrgSiteServiceProvider::class ); + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->conversation = $this->create_conversation( $this->create_mailbox(), $this->create_sender() ); + HelpScoutLinks::record( self::HELPSCOUT_ID, (int) $this->conversation->id ); + } + + /** + * Both forms of a HelpScout link open the conversation. + * + * @dataProvider data_links + * + * @param string $path Link's path. + * @return void + */ + public function test_redirects_to_conversation( string $path ): void { + $this->actingAs( $this->create_user() ); + + $this->get( $path )->assertRedirect( $this->conversation->url() ); + } + + /** + * HelpScout's links: as HelpScout made them, and with only the ID, which is core's route. + * + * @return array> + */ + public static function data_links(): array { + return array( + 'ID and number' => array( '/conversation/' . self::HELPSCOUT_ID . '/123456/?folderId=42' ), + 'ID only' => array( '/conversation/' . self::HELPSCOUT_ID ), + ); + } + + /** + * A FreeScout conversation isn't redirected, even when its ID is also a recorded HelpScout ID. + * + * @return void + */ + public function test_leaves_freescout_conversations_alone(): void { + $other = $this->create_conversation( $this->create_mailbox( 'Data' ), $this->create_sender() ); + HelpScoutLinks::record( (int) $this->conversation->id, (int) $other->id ); + $this->actingAs( $this->create_user() ); + + \DB::enableQueryLog(); + $response = $this->get( $this->conversation->url() ); + $queries = implode( "\n", array_column( \DB::getQueryLog(), 'query' ) ); + + // Core's template fails on PHP 8.4 deprecations under test, so only the redirect is checked. + $this->assertFalse( $response->isRedirect() ); + $this->assertStringNotContainsString( HelpScoutLinks::TABLE, $queries ); + } + + /** + * An agent without access to the conversation's mailbox isn't told where it is. + * + * @return void + */ + public function test_hides_conversation_from_agent_without_access(): void { + $this->actingAs( $this->create_user( User::ROLE_USER ) ); + + $this->get( '/conversation/' . self::HELPSCOUT_ID . '/123456' )->assertStatus( 404 ); + } + + /** + * The importer's IDs are copied as the table is created, and those it saves later are added. + * + * @return void + */ + public function test_keeps_importer_ids(): void { + \DB::table( HelpScoutLinks::TABLE )->delete(); + \DB::table( HelpScoutLinks::IMPORTER_TABLE )->insert( + array( + 'helpscout_id' => self::HELPSCOUT_ID, + 'helpscout_number' => 123456, + 'conversation_id' => $this->conversation->id, + ) + ); + + HelpScoutLinks::copy_imported(); + + $imported = $this->create_conversation( $this->create_mailbox( 'Data' ), $this->create_sender() ); + ImportedConversation::query()->create( + array( + 'helpscout_id' => self::HELPSCOUT_ID + 1, + 'helpscout_number' => 123457, + 'conversation_id' => $imported->id, + ) + ); + + $this->assertSame( + array( + self::HELPSCOUT_ID => (int) $this->conversation->id, + self::HELPSCOUT_ID + 1 => (int) $imported->id, + ), + \DB::table( HelpScoutLinks::TABLE )->orderBy( 'helpscout_id' )->pluck( 'conversation_id', 'helpscout_id' )->map( 'intval' )->all() + ); + } +}