Skip to content

Commit e0217fd

Browse files
authored
Ensured Ghost accounts are ranked higher during search (#1449)
ref https://linear.app/ghost/issue/BER-3069/prioritise-ghost-sites-higher Ensured Ghost accounts are ranked higher during search by checking if the account is "internal" on the instance or not
1 parent 39fac4b commit e0217fd

File tree

2 files changed

+115
-1
lines changed

2 files changed

+115
-1
lines changed

src/http/api/views/account.search.view.integration.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,49 @@ describe('AccountSearchView', () => {
417417
expect(accounts[2].name).toBe('Test Charlie');
418418
});
419419

420+
it('should prioritize Ghost sites (internal accounts) over external accounts', async () => {
421+
const [viewer] = await fixtureManager.createInternalAccount();
422+
423+
// Create an internal account (Ghost site) - has user record
424+
// Use a name starting with Z to ensure alphabetical sort would put it last
425+
const [ghostSite] = await fixtureManager.createInternalAccount();
426+
427+
await db('accounts')
428+
.where('id', ghostSite.id)
429+
.update({ name: 'Test Zebra Ghost Site' });
430+
431+
// Create external accounts - no user record
432+
// Use names starting with A and B to ensure alphabetical sort would put them first
433+
await db('accounts').insert([
434+
{
435+
ap_id: 'https://example.com/users/alice',
436+
username: 'alice',
437+
domain: 'example.com',
438+
ap_inbox_url: 'https://example.com/users/alice/inbox',
439+
name: 'Test Alice External',
440+
},
441+
{
442+
ap_id: 'https://example.com/users/bob',
443+
username: 'bob',
444+
domain: 'example.com',
445+
ap_inbox_url: 'https://example.com/users/bob/inbox',
446+
name: 'Test Bob External',
447+
},
448+
]);
449+
450+
const accounts = await accountSearchView.searchByName(
451+
'Test',
452+
viewer.id,
453+
);
454+
455+
expect(accounts).toHaveLength(3);
456+
// Ghost site should appear first despite having name starting with Z
457+
expect(accounts[0].name).toBe('Test Zebra Ghost Site');
458+
// External accounts should be sorted alphabetically after Ghost sites
459+
expect(accounts[1].name).toBe('Test Alice External');
460+
expect(accounts[2].name).toBe('Test Bob External');
461+
});
462+
420463
it('should limit results to maximum', async () => {
421464
const [viewer] = await fixtureManager.createInternalAccount();
422465

@@ -799,5 +842,50 @@ describe('AccountSearchView', () => {
799842
expect(accounts).toHaveLength(1);
800843
expect(accounts[0].handle).toBe('@alice@example.com');
801844
});
845+
846+
it('should prioritize Ghost sites (internal accounts) over external accounts', async () => {
847+
const [viewer] = await fixtureManager.createInternalAccount();
848+
849+
// Create an internal account (Ghost site)
850+
// Use a name starting with Z to ensure alphabetical sort would put it last
851+
const [ghostSite] = await fixtureManager.createInternalAccount();
852+
await db('accounts')
853+
.where('id', ghostSite.id)
854+
.update({ name: 'Zebra Ghost Site' });
855+
856+
const ghostSiteDomain = new URL(ghostSite.apId.toString()).hostname;
857+
858+
// Create external accounts on the same domain as the ghost site - no user record
859+
// Use names starting with A and B to ensure alphabetical sort would put them first
860+
await db('accounts').insert([
861+
{
862+
ap_id: `https://${ghostSiteDomain}/users/alice`,
863+
username: 'alice',
864+
domain: ghostSiteDomain,
865+
ap_inbox_url: `https://${ghostSiteDomain}/users/alice/inbox`,
866+
name: 'Alice External',
867+
},
868+
{
869+
ap_id: `https://${ghostSiteDomain}/users/bob`,
870+
username: 'bob',
871+
domain: ghostSiteDomain,
872+
ap_inbox_url: `https://${ghostSiteDomain}/users/bob/inbox`,
873+
name: 'Bob External',
874+
},
875+
]);
876+
877+
const accounts = await accountSearchView.searchByDomain(
878+
ghostSiteDomain,
879+
viewer.id,
880+
);
881+
882+
// Should have 3 accounts (ghostSite + 2 external)
883+
expect(accounts).toHaveLength(3);
884+
// Ghost site should appear first despite having name starting with Z
885+
expect(accounts[0].name).toBe('Zebra Ghost Site');
886+
// External accounts should be sorted alphabetically after Ghost sites
887+
expect(accounts[1].name).toBe('Alice External');
888+
expect(accounts[2].name).toBe('Bob External');
889+
});
802890
});
803891
});

src/http/api/views/account.search.view.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,24 @@ export class AccountSearchView {
4949
END AS followed_by_me
5050
`),
5151
)
52+
// Compute is_ghost_site (has associated user record)
53+
.select(
54+
this.db.raw(`
55+
CASE
56+
WHEN users.account_id IS NOT NULL THEN 1
57+
ELSE 0
58+
END AS is_ghost_site
59+
`),
60+
)
5261
.leftJoin('follows', function () {
5362
this.on('follows.following_id', 'accounts.id').andOnVal(
5463
'follows.follower_id',
5564
'=',
5665
viewerAccountId.toString(),
5766
);
5867
})
68+
// Join users table to detect Ghost sites (internal accounts)
69+
.leftJoin('users', 'users.account_id', 'accounts.id')
5970
// Filter out blocked accounts
6071
.leftJoin('blocks', function () {
6172
this.on('blocks.blocked_id', 'accounts.id').andOnVal(
@@ -76,7 +87,8 @@ export class AccountSearchView {
7687
})
7788
.whereNull('blocks.id')
7889
.whereNull('domain_blocks.id')
79-
// Order by name alphabetically
90+
// Order by Ghost sites first, then alphabetically by name
91+
.orderBy('is_ghost_site', 'desc')
8092
.orderBy('accounts.name', 'asc')
8193
// Limit results
8294
.limit(SEARCH_RESULT_LIMIT);
@@ -127,13 +139,24 @@ export class AccountSearchView {
127139
END AS followed_by_me
128140
`),
129141
)
142+
// Compute is_ghost_site (has associated user record)
143+
.select(
144+
this.db.raw(`
145+
CASE
146+
WHEN users.account_id IS NOT NULL THEN 1
147+
ELSE 0
148+
END AS is_ghost_site
149+
`),
150+
)
130151
.leftJoin('follows', function () {
131152
this.on('follows.following_id', 'accounts.id').andOnVal(
132153
'follows.follower_id',
133154
'=',
134155
viewerAccountId.toString(),
135156
);
136157
})
158+
// Join users table to detect Ghost sites (internal accounts)
159+
.leftJoin('users', 'users.account_id', 'accounts.id')
137160
// Filter out blocked accounts
138161
.leftJoin('blocks', function () {
139162
this.on('blocks.blocked_id', 'accounts.id').andOnVal(
@@ -154,6 +177,9 @@ export class AccountSearchView {
154177
})
155178
.whereNull('blocks.id')
156179
.whereNull('domain_blocks.id')
180+
// Order by Ghost sites first, then alphabetically by name
181+
.orderBy('is_ghost_site', 'desc')
182+
.orderBy('accounts.name', 'asc')
157183
// Limit results
158184
.limit(limit);
159185

0 commit comments

Comments
 (0)