Added an option to recommend back in Admin Settings (#18478)

refs https://github.com/TryGhost/Product/issues/3978

- added "GET /incoming_recommendations/" browse endpoint to the Admin
API - we store incoming recommendations as mentions in the database. The
new endpoint reuses the Mentions API underneath to fetch verified
mentions of type recommendation - recommendation-specific attributes are
returned by the new endpoint, including calculated fields such as the
"RecommendingBack" boolean
- show "Recommend back" option for sites recommending me, only if I
haven't recommended the site already
This commit is contained in:
Sag 2023-10-04 12:22:04 -03:00 committed by GitHub
parent e06a5825dc
commit 15adb254f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 198 additions and 97 deletions

View File

@ -1,42 +0,0 @@
import {InfiniteData} from '@tanstack/react-query';
import {Meta, createInfiniteQuery} from '../utils/api/hooks';
export type Mention = {
id: string;
source: string;
source_title: string|null;
source_site_title: string|null;
source_excerpt: string|null;
source_author: string|null;
source_featured_image: string|null;
source_favicon: string|null;
target: string;
verified: boolean;
created_at: string;
};
export interface MentionsResponseType {
meta?: Meta
mentions: Mention[];
}
const dataType = 'MentionsResponseType';
export const useBrowseMentions = createInfiniteQuery<MentionsResponseType>({
dataType,
path: '/mentions/',
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<MentionsResponseType>;
let mentions = pages.flatMap(page => page.mentions);
// Remove duplicates
mentions = mentions.filter((mention, index) => {
return mentions.findIndex(({id}) => id === mention.id) === index;
});
return {
mentions,
meta: pages[pages.length - 1].meta
};
}
});

View File

@ -99,3 +99,37 @@ export const useGetRecommendationByUrl = () => {
}
};
};
export type IncomingRecommendation = {
id: string
title: string
url: string
excerpt: string|null
featured_image: string|null
favicon: string|null
recommending_back: boolean
}
export interface IncomingRecommendationResponseType {
meta?: Meta
recommendations: IncomingRecommendation[]
}
export const useBrowseIncomingRecommendations = createInfiniteQuery<IncomingRecommendationResponseType>({
dataType,
path: '/incoming_recommendations/',
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<IncomingRecommendationResponseType>;
let recommendations = pages.flatMap(page => page.recommendations);
// Remove duplicates
recommendations = recommendations.filter((mention, index) => {
return recommendations.findIndex(({id}) => id === mention.id) === index;
});
return {
recommendations,
meta: pages[pages.length - 1].meta
};
}
});

View File

@ -7,8 +7,7 @@ import TabView from '../../../admin-x-ds/global/TabView';
import useRouting from '../../../hooks/useRouting';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {ShowMoreData} from '../../../admin-x-ds/global/Table';
import {useBrowseMentions} from '../../../api/mentions';
import {useBrowseRecommendations} from '../../../api/recommendations';
import {useBrowseIncomingRecommendations, useBrowseRecommendations} from '../../../api/recommendations';
import {useReferrerHistory} from '../../../api/referrers';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
@ -52,11 +51,10 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
loadMore: fetchNextPage
};
// Fetch "Recommending you" (mentions & stats)
const {data: {mentions, meta: mentionsMeta} = {}, isLoading: areMentionsLoading, hasNextPage: hasMentionsNextPage, fetchNextPage: fetchMentionsNextPage} = useBrowseMentions({
// Fetch "Recommending you", including stats
const {data: {recommendations: incomingRecommendations, meta: incomingRecommendationsMeta} = {}, isLoading: areIncomingRecommendationsLoading, hasNextPage: hasIncomingRecommendationsNextPage, fetchNextPage: fetchIncomingRecommendationsNextPage} = useBrowseIncomingRecommendations({
searchParams: {
limit: '5',
filter: `source:~$'/.well-known/recommendations.json'+verified:true`,
order: 'created_at desc'
},
@ -81,12 +79,12 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
keepPreviousData: true
});
const showMoreMentions: ShowMoreData = {
hasMore: !!hasMentionsNextPage,
loadMore: fetchMentionsNextPage
};
const {data: {stats} = {}, isLoading: areStatsLoading} = useReferrerHistory({});
const {data: {stats: mentionsStats} = {}, isLoading: areSourcesLoading} = useReferrerHistory({});
const showMoreMentions: ShowMoreData = {
hasMore: !!hasIncomingRecommendationsNextPage,
loadMore: fetchIncomingRecommendationsNextPage
};
// Select "Your recommendations" by default
const [selectedTab, setSelectedTab] = useState('your-recommendations');
@ -101,8 +99,8 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
{
id: 'recommending-you',
title: `Recommending you`,
counter: mentionsMeta?.pagination?.total,
contents: <IncomingRecommendationList isLoading={areMentionsLoading || areSourcesLoading} mentions={mentions ?? []} showMore={showMoreMentions} stats={mentionsStats ?? []}/>
counter: incomingRecommendationsMeta?.pagination?.total,
contents: <IncomingRecommendationList incomingRecommendations={incomingRecommendations ?? []} isLoading={areIncomingRecommendationsLoading || areStatsLoading} showMore={showMoreMentions} stats={stats ?? []}/>
}
];

View File

@ -6,26 +6,26 @@ import Table, {ShowMoreData} from '../../../../admin-x-ds/global/Table';
import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableRow from '../../../../admin-x-ds/global/TableRow';
import useRouting from '../../../../hooks/useRouting';
import {Mention} from '../../../../api/mentions';
import {IncomingRecommendation} from '../../../../api/recommendations';
import {PaginationData} from '../../../../hooks/usePagination';
import {ReferrerHistoryItem} from '../../../../api/referrers';
interface IncomingRecommendationListProps {
mentions: Mention[],
incomingRecommendations: IncomingRecommendation[],
stats: ReferrerHistoryItem[],
pagination?: PaginationData,
showMore?: ShowMoreData,
isLoading: boolean
}
const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHistoryItem[]}> = ({mention, stats}) => {
const cleanedSource = mention.source.replace('/.well-known/recommendations.json', '');
const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingRecommendation, stats: ReferrerHistoryItem[]}> = ({incomingRecommendation, stats}) => {
const {updateRoute} = useRouting();
const signups = useMemo(() => {
// Note: this should match the `getDomainFromUrl` method from OutboundLinkTagger
let cleanedDomain = cleanedSource;
let cleanedDomain = incomingRecommendation.url;
try {
cleanedDomain = new URL(cleanedSource).hostname.replace(/^www\./, '');
cleanedDomain = new URL(incomingRecommendation.url).hostname.replace(/^www\./, '');
} catch (_) {
// Ignore invalid urls
}
@ -36,33 +36,33 @@ const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHis
}
return s;
}, 0);
}, [stats, cleanedSource]);
}, [stats, incomingRecommendation.url]);
const showDetails = () => {
// Open url
window.open(cleanedSource, '_blank');
const recommendBack = () => {
updateRoute({route: `recommendations/add?url=${incomingRecommendation.url}`});
};
const freeMembersLabel = (signups) === 1 ? 'free member' : 'free members';
const showDetails = () => {
window.open(incomingRecommendation.url, '_blank');
};
const {updateRoute} = useRouting();
const action = (
<div className="flex items-center justify-end">
<Button color='green' label='Recommend back' size='sm' link onClick={() => {
updateRoute({route: `recommendations/add?url=${cleanedSource}`});
}} />
</div>
);
const freeMembersLabel = signups === 1 ? 'free member' : 'free members';
return (
<TableRow action={action} hideActions>
<TableRow action={
!incomingRecommendation.recommending_back && (
<div className="flex items-center justify-end">
<Button color='green' label='Recommend back' size='sm' link onClick={recommendBack}
/>
</div>
)
} hideActions>
<TableCell onClick={showDetails}>
<div className='group flex items-center gap-3 hover:cursor-pointer'>
<div className={`flex grow flex-col`}>
<div className="mb-0.5 flex items-center gap-3">
<RecommendationIcon favicon={mention.source_favicon} featured_image={mention.source_featured_image} title={mention.source_title || mention.source_site_title || cleanedSource} />
<span className='line-clamp-1'>{mention.source_title || mention.source_site_title || cleanedSource}</span>
<RecommendationIcon favicon={incomingRecommendation.favicon} featured_image={incomingRecommendation.featured_image} title={incomingRecommendation.title || incomingRecommendation.url} />
<span className='line-clamp-1'>{incomingRecommendation.title || incomingRecommendation.url}</span>
</div>
</div>
</div>
@ -74,10 +74,10 @@ const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHis
);
};
const IncomingRecommendationList: React.FC<IncomingRecommendationListProps> = ({mentions, stats, pagination, showMore, isLoading}) => {
if (isLoading || mentions.length) {
const IncomingRecommendationList: React.FC<IncomingRecommendationListProps> = ({incomingRecommendations, stats, pagination, showMore, isLoading}) => {
if (isLoading || incomingRecommendations.length) {
return <Table isLoading={isLoading} pagination={pagination} showMore={showMore} hintSeparator>
{mentions.map(mention => <IncomingRecommendationItem key={mention.id} mention={mention} stats={stats} />)}
{incomingRecommendations.map(rec => <IncomingRecommendationItem key={rec.id} incomingRecommendation={rec} stats={stats} />)}
</Table>;
} else {
return <NoValueLabel>

View File

@ -0,0 +1,20 @@
const recommendations = require('../../services/recommendations');
module.exports = {
docName: 'recommendations',
browse: {
headers: {
cacheInvalidate: false
},
options: [
'limit',
'page'
],
permissions: true,
validation: {},
async query(frame) {
return await recommendations.incomingRecommendationController.browse(frame);
}
}
};

View File

@ -209,6 +209,10 @@ module.exports = {
return apiFramework.pipeline(require('./recommendations'), localUtils);
},
get incomingRecommendations() {
return apiFramework.pipeline(require('./incoming-recommendations'), localUtils);
},
/**
* Content API Controllers
*

View File

@ -28,6 +28,11 @@ class RecommendationServiceWrapper {
*/
service;
/**
* @type {import('@tryghost/recommendations').IncomingRecommendationController}
*/
incomingRecommendationController;
/**
* @type {import('@tryghost/recommendations').IncomingRecommendationService}
*/
@ -51,6 +56,7 @@ class RecommendationServiceWrapper {
RecommendationController,
WellknownService,
BookshelfClickEventRepository,
IncomingRecommendationController,
IncomingRecommendationService,
IncomingRecommendationEmailRenderer
} = require('@tryghost/recommendations');
@ -125,6 +131,10 @@ class RecommendationServiceWrapper {
service: this.service
});
this.incomingRecommendationController = new IncomingRecommendationController({
service: this.incomingRecommendationService
});
if (labs.isSet('recommendations')) {
this.service.init().catch(logging.error);
this.incomingRecommendationService.init().catch(logging.error);

View File

@ -354,5 +354,8 @@ module.exports = function apiRoutes() {
router.put('/recommendations/:id', mw.authAdminApi, http(api.recommendations.edit));
router.del('/recommendations/:id', mw.authAdminApi, http(api.recommendations.destroy));
// Incoming recommendations
router.get('/incoming_recommendations', mw.authAdminApi, http(api.incomingRecommendations.browse));
return router;
};

View File

@ -11,7 +11,7 @@
"build": "tsc",
"build:ts": "yarn build",
"prepare": "tsc",
"test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'",
"test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'",
"test": "yarn test:types && yarn test:unit",
"test:types": "tsc --noEmit",
"lint:code": "eslint src/ --ext .ts --cache",

View File

@ -0,0 +1,9 @@
export type IncomingRecommendation = {
id: string;
title: string;
url: URL;
excerpt: string|null;
favicon: URL|null;
featuredImage: URL|null;
recommendingBack: boolean;
}

View File

@ -0,0 +1,50 @@
import {IncomingRecommendationService} from './IncomingRecommendationService';
import {IncomingRecommendation} from './IncomingRecommendation';
import {UnsafeData} from './UnsafeData';
type Frame = {
data: unknown,
options: unknown,
};
type Meta = {
pagination: object,
}
export class IncomingRecommendationController {
service: IncomingRecommendationService;
constructor(deps: {service: IncomingRecommendationService}) {
this.service = deps.service;
}
async browse(frame: Frame) {
const options = new UnsafeData(frame.options);
const page = options.optionalKey('page')?.integer ?? 1;
const limit = options.optionalKey('limit')?.integer ?? 5;
const {incomingRecommendations, meta} = await this.service.listIncomingRecommendations({page, limit});
return this.#serialize(
incomingRecommendations,
meta
);
}
#serialize(recommendations: IncomingRecommendation[], meta?: Meta) {
return {
data: recommendations.map((entity) => {
return {
id: entity.id,
title: entity.title,
excerpt: entity.excerpt,
featured_image: entity.featuredImage?.toString() ?? null,
favicon: entity.favicon?.toString() ?? null,
url: entity.url.toString(),
recommending_back: !!entity.recommendingBack
};
}),
meta
};
}
}

View File

@ -1,4 +1,5 @@
import {IncomingRecommendation, EmailRecipient} from './IncomingRecommendationService';
import {EmailRecipient} from './IncomingRecommendationService';
import {IncomingRecommendation} from './IncomingRecommendation';
type StaffService = {
api: {
@ -17,7 +18,7 @@ export class IncomingRecommendationEmailRenderer {
}
async renderSubject(recommendation: IncomingRecommendation) {
return `👍 New recommendation: ${recommendation.siteTitle}`;
return `👍 New recommendation: ${recommendation.title}`;
}
async renderHTML(recommendation: IncomingRecommendation, recipient: EmailRecipient) {

View File

@ -1,17 +1,8 @@
import {IncomingRecommendation} from './IncomingRecommendation';
import {IncomingRecommendationEmailRenderer} from './IncomingRecommendationEmailRenderer';
import {RecommendationService} from './RecommendationService';
import logging from '@tryghost/logging';
export type IncomingRecommendation = {
title: string;
siteTitle: string|null;
url: URL;
excerpt: string|null;
favicon: URL|null;
featuredImage: URL|null;
recommendingBack: boolean;
}
export type Report = {
startDate: Date,
endDate: Date,
@ -19,6 +10,7 @@ export type Report = {
}
type Mention = {
id: string,
source: URL,
sourceTitle: string,
sourceSiteTitle: string|null,
@ -28,9 +20,13 @@ type Mention = {
sourceFeaturedImage: URL|null
}
type MentionMeta = {
pagination: object,
}
type MentionsAPI = {
refreshMentions(options: {filter: string, limit: number|'all'}): Promise<void>
listMentions(options: {filter: string, limit: number|'all'}): Promise<{data: Mention[]}>
listMentions(options: {filter: string, page: number, limit: number|'all'}): Promise<{data: Mention[], meta?: MentionMeta}>
}
export type EmailRecipient = {
@ -101,8 +97,8 @@ export class IncomingRecommendationService {
const recommendingBack = !!existing;
return {
title: mention.sourceTitle,
siteTitle: mention.sourceSiteTitle,
id: mention.id,
title: mention.sourceSiteTitle || mention.sourceTitle,
url,
excerpt: mention.sourceExcerpt,
favicon: mention.sourceFavicon,
@ -130,4 +126,19 @@ export class IncomingRecommendationService {
await this.#emailService.send(recipient.email, subject, html, text);
}
}
async listIncomingRecommendations(options: { page?: number; limit?: number|'all'}): Promise<{ incomingRecommendations: IncomingRecommendation[]; meta?: MentionMeta }> {
const page = options.page ?? 1;
const limit = options.limit ?? 5;
const filter = this.#getMentionFilter();
const mentions = await this.#mentionsApi.listMentions({filter, page, limit});
const mentionsToIncomingRecommendations = await Promise.all(mentions.data.map(mention => this.#mentionToIncomingRecommendation(mention)));
const incomingRecommendations = mentionsToIncomingRecommendations.filter((recommendation): recommendation is IncomingRecommendation => !!recommendation);
return {
incomingRecommendations,
meta: mentions.meta
};
}
}

View File

@ -9,5 +9,6 @@ export * from './ClickEvent';
export * from './BookshelfClickEventRepository';
export * from './SubscribeEvent';
export * from './BookshelfSubscribeEventRepository';
export * from './IncomingRecommendationController';
export * from './IncomingRecommendationService';
export * from './IncomingRecommendationEmailRenderer';

View File

@ -76,6 +76,7 @@ describe('IncomingRecommendationService', function () {
describe('sendRecommendationEmail', function () {
it('should send email', async function () {
await service.sendRecommendationEmail({
id: 'test',
source: new URL('https://example.com'),
sourceTitle: 'Example',
sourceSiteTitle: 'Example',
@ -90,6 +91,7 @@ describe('IncomingRecommendationService', function () {
it('ignores when mention not convertable to incoming recommendation', async function () {
readRecommendationByUrl.rejects(new Error('test'));
await service.sendRecommendationEmail({
id: 'test',
source: new URL('https://example.com'),
sourceTitle: 'Example',
sourceSiteTitle: 'Example',

View File

@ -3,7 +3,7 @@ module.exports = function (data) {
// Be careful when you indent the email, because whitespaces are visible in emails!
return `
You have been recommended by ${recommendation.siteTitle || recommendation.title || recommendation.url}.
You have been recommended by ${recommendation.title || recommendation.url}.
---