ghost 5.115.1 → 5.116.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/{tryghost-api-framework-5.115.1.tgz → tryghost-api-framework-5.116.1.tgz} +0 -0
- package/components/tryghost-constants-5.116.1.tgz +0 -0
- package/components/tryghost-custom-fonts-5.116.1.tgz +0 -0
- package/components/{tryghost-custom-theme-settings-service-5.115.1.tgz → tryghost-custom-theme-settings-service-5.116.1.tgz} +0 -0
- package/components/{tryghost-domain-events-5.115.1.tgz → tryghost-domain-events-5.116.1.tgz} +0 -0
- package/components/{tryghost-donations-5.115.1.tgz → tryghost-donations-5.116.1.tgz} +0 -0
- package/components/tryghost-email-addresses-5.116.1.tgz +0 -0
- package/components/tryghost-email-service-5.116.1.tgz +0 -0
- package/components/tryghost-email-suppression-list-5.116.1.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.116.1.tgz +0 -0
- package/components/tryghost-i18n-5.116.1.tgz +0 -0
- package/components/tryghost-job-manager-5.116.1.tgz +0 -0
- package/components/tryghost-link-replacer-5.116.1.tgz +0 -0
- package/components/tryghost-magic-link-5.116.1.tgz +0 -0
- package/components/{tryghost-member-attribution-5.115.1.tgz → tryghost-member-attribution-5.116.1.tgz} +0 -0
- package/components/tryghost-member-events-5.116.1.tgz +0 -0
- package/components/tryghost-members-api-5.116.1.tgz +0 -0
- package/components/tryghost-members-csv-5.116.1.tgz +0 -0
- package/components/{tryghost-members-offers-5.115.1.tgz → tryghost-members-offers-5.116.1.tgz} +0 -0
- package/components/{tryghost-milestones-5.115.1.tgz → tryghost-milestones-5.116.1.tgz} +0 -0
- package/components/{tryghost-mw-error-handler-5.115.1.tgz → tryghost-mw-error-handler-5.116.1.tgz} +0 -0
- package/components/tryghost-mw-vhost-5.116.1.tgz +0 -0
- package/components/{tryghost-post-events-5.115.1.tgz → tryghost-post-events-5.116.1.tgz} +0 -0
- package/components/{tryghost-post-revisions-5.115.1.tgz → tryghost-post-revisions-5.116.1.tgz} +0 -0
- package/components/tryghost-posts-service-5.116.1.tgz +0 -0
- package/components/{tryghost-prometheus-metrics-5.115.1.tgz → tryghost-prometheus-metrics-5.116.1.tgz} +0 -0
- package/components/tryghost-security-5.116.1.tgz +0 -0
- package/components/{tryghost-tiers-5.115.1.tgz → tryghost-tiers-5.116.1.tgz} +0 -0
- package/components/tryghost-webmentions-5.116.1.tgz +0 -0
- package/content/themes/casper/LICENSE +1 -1
- package/content/themes/casper/README.md +1 -1
- package/content/themes/source/LICENSE +1 -1
- package/content/themes/source/README.md +1 -1
- package/content/themes/source/assets/built/screen.css +1 -1
- package/content/themes/source/assets/built/screen.css.map +1 -1
- package/content/themes/source/assets/css/screen.css +6 -11
- package/content/themes/source/partials/feature-image.hbs +2 -2
- package/core/boot.js +0 -42
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +24764 -24129
- package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
- package/core/built/admin/assets/admin-x-demo/{index-15df2af5.mjs → index-a9601514.mjs} +3 -3
- package/core/built/admin/assets/admin-x-demo/{modals-8ca61d78.mjs → modals-c1789d04.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-d2e6872f.mjs → CodeEditorView-e9c9deb8.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-8e8821e5.mjs → index-84580c3a.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-f5cb3db3.mjs → index-f744cab7.mjs} +49 -35
- package/core/built/admin/assets/admin-x-settings/{modals-e8ae4d46.mjs → modals-d9ca60c5.mjs} +1198 -1192
- package/core/built/admin/assets/chunk.524.cb72a86e19c9ffd6172e.js +35 -0
- package/core/built/admin/assets/chunk.582.4f4d38ffe79fbdbd26f7.js +37 -0
- package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js → chunk.713.e9027c0cc3c56110f5da.js} +125 -98
- package/core/built/admin/assets/{ghost-df7b9558260aa27d18b195ee895b487d.js → ghost-03b64c086f3c60cabc85fe7a7e2b640a.js} +144 -145
- package/core/built/admin/assets/ghost-ba58e9822f7384461e926c7e23f04a75.css +1 -0
- package/core/built/admin/assets/ghost-dark-f1f29683b14ffa11615b3bba8b6ab92c.css +1 -0
- package/core/built/admin/assets/koenig-lexical/index.css +1 -1
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +20563 -20891
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +139 -139
- package/core/built/admin/assets/posts/posts.js +5732 -5667
- package/core/built/admin/assets/stats/stats.js +71082 -7533
- package/core/built/admin/assets/{vendor-68a4aa424a179a90f5bbc2b750def576.js → vendor-72026232b36d97babc6320917c16c321.js} +36 -34
- package/core/built/admin/index.html +6 -6
- package/core/cli/generate-data.js +1 -1
- package/core/frontend/helpers/ghost_head.js +6 -1
- package/core/frontend/public/ghost-stats.js +55 -2
- package/core/frontend/services/assets-minification/AdminAuthAssets.js +2 -1
- package/core/frontend/services/assets-minification/CardAssets.js +1 -1
- package/core/frontend/services/assets-minification/CommentCountsAssets.js +1 -1
- package/core/frontend/services/assets-minification/MemberAttributionAssets.js +1 -1
- package/core/frontend/services/assets-minification/Minifier.js +191 -0
- package/core/frontend/services/routing/controllers/previews.js +2 -1
- package/core/server/adapters/cache/Redis.js +1 -1
- package/core/server/adapters/lib/redis/AdapterCacheRedis.js +287 -0
- package/core/server/adapters/lib/redis/redis-store-factory.js +22 -0
- package/core/server/api/endpoints/posts.js +9 -3
- package/core/server/api/endpoints/previews.js +35 -1
- package/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js +6 -9
- package/core/server/api/endpoints/utils/validators/input/settings.js +1 -1
- package/core/server/data/db/connection.js +2 -0
- package/core/server/data/db/index.js +1 -0
- package/core/server/data/importer/handlers/ImporterContentFileHandler.js +90 -0
- package/core/server/data/importer/import-manager.js +1 -1
- package/core/server/data/seeders/DataGenerator.js +288 -0
- package/core/server/data/seeders/importers/BenefitsImporter.js +28 -0
- package/core/server/data/seeders/importers/CommentsImporter.js +73 -0
- package/core/server/data/seeders/importers/EmailBatchesImporter.js +38 -0
- package/core/server/data/seeders/importers/EmailRecipientFailuresImporter.js +67 -0
- package/core/server/data/seeders/importers/EmailRecipientsImporter.js +212 -0
- package/core/server/data/seeders/importers/EmailsImporter.js +99 -0
- package/core/server/data/seeders/importers/LabelsImporter.js +41 -0
- package/core/server/data/seeders/importers/MembersClickEventsImporter.js +69 -0
- package/core/server/data/seeders/importers/MembersCreatedEventsImporter.js +103 -0
- package/core/server/data/seeders/importers/MembersFeedbackImporter.js +45 -0
- package/core/server/data/seeders/importers/MembersImporter.js +111 -0
- package/core/server/data/seeders/importers/MembersLabelsImporter.js +39 -0
- package/core/server/data/seeders/importers/MembersLoginEventsImporter.js +69 -0
- package/core/server/data/seeders/importers/MembersNewslettersImporter.js +38 -0
- package/core/server/data/seeders/importers/MembersPaidSubscriptionEventsImporter.js +99 -0
- package/core/server/data/seeders/importers/MembersProductsImporter.js +42 -0
- package/core/server/data/seeders/importers/MembersStatusEventsImporter.js +58 -0
- package/core/server/data/seeders/importers/MembersStripeCustomersImporter.js +60 -0
- package/core/server/data/seeders/importers/MembersStripeCustomersSubscriptionsImporter.js +259 -0
- package/core/server/data/seeders/importers/MembersSubscribeEventsImporter.js +69 -0
- package/core/server/data/seeders/importers/MembersSubscriptionCreatedEventsImporter.js +95 -0
- package/core/server/data/seeders/importers/NewslettersImporter.js +40 -0
- package/core/server/data/seeders/importers/OffersImporter.js +70 -0
- package/core/server/data/seeders/importers/PostsAuthorsImporter.js +32 -0
- package/core/server/data/seeders/importers/PostsImporter.js +102 -0
- package/core/server/data/seeders/importers/PostsProductsImporter.js +35 -0
- package/core/server/data/seeders/importers/PostsTagsImporter.js +46 -0
- package/core/server/data/seeders/importers/ProductsBenefitsImporter.js +54 -0
- package/core/server/data/seeders/importers/ProductsImporter.js +90 -0
- package/core/server/data/seeders/importers/RecommendationClickEventsImporter.js +32 -0
- package/core/server/data/seeders/importers/RecommendationSubscribeEventsImporter.js +32 -0
- package/core/server/data/seeders/importers/RecommendationsImporter.js +34 -0
- package/core/server/data/seeders/importers/RedirectsImporter.js +49 -0
- package/core/server/data/seeders/importers/RolesUsersImporter.js +42 -0
- package/core/server/data/seeders/importers/StripePricesImporter.js +69 -0
- package/core/server/data/seeders/importers/StripeProductsImporter.js +34 -0
- package/core/server/data/seeders/importers/TableImporter.js +187 -0
- package/core/server/data/seeders/importers/TagsImporter.js +41 -0
- package/core/server/data/seeders/importers/UsersImporter.js +31 -0
- package/core/server/data/seeders/importers/WebMentionsImporter.js +42 -0
- package/core/server/data/seeders/importers/index.js +41 -0
- package/core/server/data/seeders/utils/JsonImporter.js +39 -0
- package/core/server/data/seeders/utils/blog-info.js +3 -0
- package/core/server/data/seeders/utils/database-date.js +7 -0
- package/core/server/data/seeders/utils/event-generator.js +48 -0
- package/core/server/data/seeders/utils/random.js +13 -0
- package/core/server/data/seeders/utils/topological-sort.js +33 -0
- package/core/server/services/adapter-manager/AdapterManager.js +161 -0
- package/core/server/services/adapter-manager/index.js +1 -1
- package/core/server/services/announcement-bar-service/AnnouncementBarSettings.js +54 -0
- package/core/server/services/announcement-bar-service/AnnouncementVisibilityValues.js +11 -0
- package/core/server/services/announcement-bar-service/index.js +1 -1
- package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +1 -1
- package/core/server/services/auth/session/session-service.js +15 -5
- package/core/server/services/custom-redirects/index.js +1 -1
- package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +1 -1
- package/core/server/services/email-service/EmailServiceWrapper.js +4 -4
- package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +1 -1
- package/core/server/services/email-suppression-list/service.js +1 -1
- package/core/server/services/lib/DynamicRedirectManager.js +156 -0
- package/core/server/services/lib/EmailContentGenerator.js +54 -0
- package/core/server/services/lib/InMemoryRepository.js +62 -0
- package/core/server/services/lib/InMemoryRepository.ts +80 -0
- package/core/server/services/lib/MailgunClient.js +364 -0
- package/core/server/services/link-redirection/LinkRedirect.js +26 -0
- package/core/server/services/link-redirection/LinkRedirectRepository.js +7 -7
- package/core/server/services/link-redirection/LinkRedirectsService.js +123 -0
- package/core/server/services/link-redirection/README.md +151 -0
- package/core/server/services/link-redirection/RedirectEvent.js +24 -0
- package/core/server/services/link-redirection/index.js +1 -1
- package/core/server/services/link-tracking/LinkClickTrackingService.js +1 -1
- package/core/server/services/mail/index.js +1 -1
- package/core/server/services/mail-events/InMemoryMailEventRepository.js +2 -2
- package/core/server/services/mail-events/InMemoryMailEventRepository.ts +1 -1
- package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
- package/core/server/services/offers/service.js +1 -1
- package/core/server/services/recommendations/RecommendationServiceWrapper.js +8 -8
- package/core/server/services/recommendations/service/BookshelfClickEventRepository.js +48 -0
- package/core/server/services/recommendations/service/BookshelfClickEventRepository.ts +49 -0
- package/core/server/services/recommendations/service/BookshelfRecommendationRepository.js +98 -0
- package/core/server/services/recommendations/service/BookshelfRecommendationRepository.ts +117 -0
- package/core/server/services/recommendations/service/BookshelfRepository.js +134 -0
- package/core/server/services/recommendations/service/BookshelfRepository.ts +196 -0
- package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.js +48 -0
- package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.ts +49 -0
- package/core/server/services/recommendations/service/ClickEvent.js +33 -0
- package/core/server/services/recommendations/service/ClickEvent.ts +32 -0
- package/core/server/services/recommendations/service/InMemoryRecommendationRepository.js +19 -0
- package/core/server/services/recommendations/service/InMemoryRecommendationRepository.ts +20 -0
- package/core/server/services/recommendations/service/IncomingRecommendationController.js +34 -0
- package/core/server/services/recommendations/service/IncomingRecommendationController.ts +51 -0
- package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.js +25 -0
- package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.ts +37 -0
- package/core/server/services/recommendations/service/IncomingRecommendationService.js +93 -0
- package/core/server/services/recommendations/service/IncomingRecommendationService.ts +160 -0
- package/core/server/services/recommendations/service/Recommendation.js +140 -0
- package/core/server/services/recommendations/service/Recommendation.ts +201 -0
- package/core/server/services/recommendations/service/RecommendationController.js +208 -0
- package/core/server/services/recommendations/service/RecommendationController.ts +258 -0
- package/core/server/services/recommendations/service/RecommendationMetadataService.js +86 -0
- package/core/server/services/recommendations/service/RecommendationMetadataService.ts +128 -0
- package/core/server/services/recommendations/service/RecommendationRepository.js +2 -0
- package/core/server/services/recommendations/service/RecommendationRepository.ts +13 -0
- package/core/server/services/recommendations/service/RecommendationService.js +228 -0
- package/core/server/services/recommendations/service/RecommendationService.ts +281 -0
- package/core/server/services/recommendations/service/SubscribeEvent.js +33 -0
- package/core/server/services/recommendations/service/SubscribeEvent.ts +32 -0
- package/core/server/services/recommendations/service/UnsafeData.js +183 -0
- package/core/server/services/recommendations/service/UnsafeData.ts +217 -0
- package/core/server/services/recommendations/service/WellknownService.js +36 -0
- package/core/server/services/recommendations/service/WellknownService.ts +47 -0
- package/core/server/services/recommendations/service/index.js +31 -0
- package/core/server/services/recommendations/service/index.ts +15 -0
- package/core/server/services/recommendations/service/libraries.d.ts +5 -0
- package/core/server/services/slack-notifications/SlackNotifications.js +211 -0
- package/core/server/services/slack-notifications/SlackNotificationsService.js +90 -0
- package/core/server/services/slack-notifications/service.js +4 -6
- package/core/server/web/api/endpoints/admin/app.js +1 -21
- package/core/server/web/api/middleware/version-match.js +41 -0
- package/core/shared/labs.js +2 -2
- package/package.json +87 -104
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +1470 -1540
- package/components/tryghost-adapter-cache-redis-5.115.1.tgz +0 -0
- package/components/tryghost-adapter-manager-5.115.1.tgz +0 -0
- package/components/tryghost-announcement-bar-settings-5.115.1.tgz +0 -0
- package/components/tryghost-constants-5.115.1.tgz +0 -0
- package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
- package/components/tryghost-data-generator-5.115.1.tgz +0 -0
- package/components/tryghost-email-addresses-5.115.1.tgz +0 -0
- package/components/tryghost-email-content-generator-5.115.1.tgz +0 -0
- package/components/tryghost-email-events-5.115.1.tgz +0 -0
- package/components/tryghost-email-service-5.115.1.tgz +0 -0
- package/components/tryghost-email-suppression-list-5.115.1.tgz +0 -0
- package/components/tryghost-express-dynamic-redirects-5.115.1.tgz +0 -0
- package/components/tryghost-ghost-5.115.1.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.115.1.tgz +0 -0
- package/components/tryghost-i18n-5.115.1.tgz +0 -0
- package/components/tryghost-importer-handler-content-files-5.115.1.tgz +0 -0
- package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
- package/components/tryghost-job-manager-5.115.1.tgz +0 -0
- package/components/tryghost-link-redirects-5.115.1.tgz +0 -0
- package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
- package/components/tryghost-magic-link-5.115.1.tgz +0 -0
- package/components/tryghost-mailgun-client-5.115.1.tgz +0 -0
- package/components/tryghost-member-events-5.115.1.tgz +0 -0
- package/components/tryghost-members-api-5.115.1.tgz +0 -0
- package/components/tryghost-members-csv-5.115.1.tgz +0 -0
- package/components/tryghost-members-payments-5.115.1.tgz +0 -0
- package/components/tryghost-minifier-5.115.1.tgz +0 -0
- package/components/tryghost-mw-version-match-5.115.1.tgz +0 -0
- package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
- package/components/tryghost-posts-service-5.115.1.tgz +0 -0
- package/components/tryghost-recommendations-5.115.1.tgz +0 -0
- package/components/tryghost-security-5.115.1.tgz +0 -0
- package/components/tryghost-slack-notifications-5.115.1.tgz +0 -0
- package/components/tryghost-webmentions-5.115.1.tgz +0 -0
- package/core/built/admin/assets/chunk.524.2439684964c164c598ab.js +0 -35
- package/core/built/admin/assets/chunk.582.bf5a2bbb2c4eb69ef1e7.js +0 -37
- package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +0 -1
- package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +0 -1
- /package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js.LICENSE.txt → chunk.713.e9027c0cc3c56110f5da.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* eslint-disable ghost/filenames/match-exported-class */
|
|
2
|
+
|
|
3
|
+
import ObjectId from 'bson-objectid';
|
|
4
|
+
import errors from '@tryghost/errors';
|
|
5
|
+
import {UnsafeData} from './UnsafeData';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* We never expose Entities outside of services. Because we should never expose the bussiness logic methods. The plain objects are used for that
|
|
9
|
+
*/
|
|
10
|
+
export type RecommendationPlain = {
|
|
11
|
+
id: string,
|
|
12
|
+
title: string
|
|
13
|
+
description: string|null
|
|
14
|
+
excerpt: string|null // Fetched from the site meta data
|
|
15
|
+
featuredImage: URL|null // Fetched from the site meta data
|
|
16
|
+
favicon: URL|null // Fetched from the site meta data
|
|
17
|
+
url: URL
|
|
18
|
+
oneClickSubscribe: boolean,
|
|
19
|
+
createdAt: Date,
|
|
20
|
+
updatedAt: Date|null,
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* These are read only, you cannot change them
|
|
24
|
+
*/
|
|
25
|
+
clickCount?: number
|
|
26
|
+
subscriberCount?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type RecommendationCreateData = {
|
|
30
|
+
id?: string
|
|
31
|
+
title: string
|
|
32
|
+
description: string|null
|
|
33
|
+
excerpt: string|null // Fetched from the site meta data
|
|
34
|
+
featuredImage: URL|string|null // Fetched from the site meta data
|
|
35
|
+
favicon: URL|string|null // Fetched from the site meta data
|
|
36
|
+
url: URL|string
|
|
37
|
+
oneClickSubscribe: boolean
|
|
38
|
+
createdAt?: Date
|
|
39
|
+
updatedAt?: Date|null,
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* These are read only, you cannot change them
|
|
43
|
+
*/
|
|
44
|
+
clickCount?: number
|
|
45
|
+
subscriberCount?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AddRecommendation = Omit<RecommendationCreateData, 'id'|'createdAt'|'updatedAt'>
|
|
49
|
+
export type EditRecommendation = Partial<AddRecommendation>
|
|
50
|
+
|
|
51
|
+
export class Recommendation {
|
|
52
|
+
id: string;
|
|
53
|
+
title: string;
|
|
54
|
+
description: string|null;
|
|
55
|
+
excerpt: string|null; // Fetched from the site meta data
|
|
56
|
+
featuredImage: URL|null; // Fetched from the site meta data
|
|
57
|
+
favicon: URL|null; // Fetched from the site meta data
|
|
58
|
+
url: URL;
|
|
59
|
+
oneClickSubscribe: boolean;
|
|
60
|
+
createdAt: Date;
|
|
61
|
+
updatedAt: Date|null;
|
|
62
|
+
#clickCount: number|undefined;
|
|
63
|
+
#subscriberCount: number|undefined;
|
|
64
|
+
|
|
65
|
+
#deleted: boolean;
|
|
66
|
+
|
|
67
|
+
get deleted() {
|
|
68
|
+
return this.#deleted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get clickCount() {
|
|
72
|
+
return this.#clickCount;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get subscriberCount() {
|
|
76
|
+
return this.#subscriberCount;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private constructor(data: RecommendationPlain) {
|
|
80
|
+
this.id = data.id;
|
|
81
|
+
this.title = data.title;
|
|
82
|
+
this.description = data.description;
|
|
83
|
+
this.excerpt = data.excerpt;
|
|
84
|
+
this.featuredImage = data.featuredImage;
|
|
85
|
+
this.favicon = data.favicon;
|
|
86
|
+
this.url = data.url;
|
|
87
|
+
this.oneClickSubscribe = data.oneClickSubscribe;
|
|
88
|
+
this.createdAt = data.createdAt;
|
|
89
|
+
this.updatedAt = data.updatedAt;
|
|
90
|
+
this.#clickCount = data.clickCount;
|
|
91
|
+
this.#subscriberCount = data.subscriberCount;
|
|
92
|
+
this.#deleted = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static validate(properties: AddRecommendation) {
|
|
96
|
+
if (properties.title.length === 0) {
|
|
97
|
+
throw new errors.ValidationError({
|
|
98
|
+
message: 'Title must not be empty'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (properties.title.length > 2000) {
|
|
103
|
+
throw new errors.ValidationError({
|
|
104
|
+
message: 'Title must be less than 2000 characters'
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (properties.description && properties.description.length > 200) {
|
|
109
|
+
throw new errors.ValidationError({
|
|
110
|
+
message: 'Description must be less than 200 characters'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clean() {
|
|
116
|
+
if (this.description !== null && this.description.length === 0) {
|
|
117
|
+
this.description = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.excerpt !== null && this.excerpt.length === 0) {
|
|
121
|
+
this.excerpt = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.excerpt !== null && this.excerpt.length > 2000) {
|
|
125
|
+
this.excerpt = this.excerpt.slice(0, 1997) + '...';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.createdAt.setMilliseconds(0);
|
|
129
|
+
this.updatedAt?.setMilliseconds(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
static create(data: RecommendationCreateData) {
|
|
133
|
+
const id = data.id ?? ObjectId().toString();
|
|
134
|
+
|
|
135
|
+
const d = {
|
|
136
|
+
id,
|
|
137
|
+
title: data.title,
|
|
138
|
+
description: data.description,
|
|
139
|
+
excerpt: data.excerpt,
|
|
140
|
+
featuredImage: new UnsafeData(data.featuredImage, {field: ['featuredImage']}).nullable.url,
|
|
141
|
+
favicon: new UnsafeData(data.favicon, {field: ['favicon']}).nullable.url,
|
|
142
|
+
url: new UnsafeData(data.url, {field: ['url']}).url,
|
|
143
|
+
oneClickSubscribe: data.oneClickSubscribe,
|
|
144
|
+
createdAt: data.createdAt ?? new Date(),
|
|
145
|
+
updatedAt: data.updatedAt ?? null,
|
|
146
|
+
clickCount: data.clickCount,
|
|
147
|
+
subscriberCount: data.subscriberCount
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.validate(d);
|
|
151
|
+
const recommendation = new Recommendation(d);
|
|
152
|
+
recommendation.clean();
|
|
153
|
+
|
|
154
|
+
return recommendation;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get plain(): RecommendationPlain {
|
|
158
|
+
return {
|
|
159
|
+
id: this.id,
|
|
160
|
+
title: this.title,
|
|
161
|
+
description: this.description,
|
|
162
|
+
excerpt: this.excerpt,
|
|
163
|
+
featuredImage: this.featuredImage,
|
|
164
|
+
favicon: this.favicon,
|
|
165
|
+
url: this.url,
|
|
166
|
+
oneClickSubscribe: this.oneClickSubscribe,
|
|
167
|
+
createdAt: this.createdAt,
|
|
168
|
+
updatedAt: this.updatedAt,
|
|
169
|
+
clickCount: this.clickCount,
|
|
170
|
+
subscriberCount: this.subscriberCount
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Change the specified properties. Properties that are set to undefined will not be changed
|
|
176
|
+
*/
|
|
177
|
+
edit(properties: EditRecommendation) {
|
|
178
|
+
// Delete undefined properties
|
|
179
|
+
const newProperties = this.plain;
|
|
180
|
+
let didChange = false;
|
|
181
|
+
|
|
182
|
+
for (const key of Object.keys(properties) as (keyof EditRecommendation)[]) {
|
|
183
|
+
if (Object.prototype.hasOwnProperty.call(properties, key) && properties[key] !== undefined && properties[key] !== newProperties[key]) {
|
|
184
|
+
(newProperties as Record<string, unknown>)[key] = properties[key] as unknown;
|
|
185
|
+
didChange = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!didChange) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
newProperties.updatedAt = new Date();
|
|
193
|
+
|
|
194
|
+
const created = Recommendation.create(newProperties);
|
|
195
|
+
Object.assign(this, created);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
delete() {
|
|
199
|
+
this.#deleted = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RecommendationController = void 0;
|
|
7
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
8
|
+
const errors_1 = __importDefault(require("@tryghost/errors"));
|
|
9
|
+
const UnsafeData_1 = require("./UnsafeData");
|
|
10
|
+
const RecommendationIncludesMap = {
|
|
11
|
+
'count.clicks': 'clickCount',
|
|
12
|
+
'count.subscribers': 'subscriberCount'
|
|
13
|
+
};
|
|
14
|
+
const RecommendationOrderMap = {
|
|
15
|
+
title: 'title',
|
|
16
|
+
description: 'description',
|
|
17
|
+
excerpt: 'excerpt',
|
|
18
|
+
one_click_subscribe: 'oneClickSubscribe',
|
|
19
|
+
created_at: 'createdAt',
|
|
20
|
+
updated_at: 'updatedAt',
|
|
21
|
+
'count.clicks': 'clickCount',
|
|
22
|
+
'count.subscribers': 'subscriberCount'
|
|
23
|
+
};
|
|
24
|
+
class RecommendationController {
|
|
25
|
+
service;
|
|
26
|
+
constructor(deps) {
|
|
27
|
+
this.service = deps.service;
|
|
28
|
+
}
|
|
29
|
+
async read(frame) {
|
|
30
|
+
const options = new UnsafeData_1.UnsafeData(frame.options);
|
|
31
|
+
const id = options.key('id').string;
|
|
32
|
+
const recommendation = await this.service.readRecommendation(id);
|
|
33
|
+
return this.#serialize([recommendation]);
|
|
34
|
+
}
|
|
35
|
+
async add(frame) {
|
|
36
|
+
const data = new UnsafeData_1.UnsafeData(frame.data);
|
|
37
|
+
const recommendation = data.key('recommendations').index(0);
|
|
38
|
+
const plain = {
|
|
39
|
+
title: recommendation.key('title').string,
|
|
40
|
+
url: recommendation.key('url').url,
|
|
41
|
+
// Optional fields
|
|
42
|
+
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean ?? false,
|
|
43
|
+
description: recommendation.optionalKey('description')?.nullable.string ?? null,
|
|
44
|
+
excerpt: recommendation.optionalKey('excerpt')?.nullable.string ?? null,
|
|
45
|
+
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url ?? null,
|
|
46
|
+
favicon: recommendation.optionalKey('favicon')?.nullable.url ?? null
|
|
47
|
+
};
|
|
48
|
+
return this.#serialize([await this.service.addRecommendation(plain)]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Given a recommendation URL, returns either an existing recommendation with that url and updated metadata,
|
|
52
|
+
* or the metadata from that URL as if it would create a new one (without creating a new one)
|
|
53
|
+
*
|
|
54
|
+
* This can be used in the frontend when creating a new recommendation (duplication checking + showing a preview before saving)
|
|
55
|
+
*/
|
|
56
|
+
async check(frame) {
|
|
57
|
+
const data = new UnsafeData_1.UnsafeData(frame.data);
|
|
58
|
+
const recommendation = data.key('recommendations').index(0);
|
|
59
|
+
const url = recommendation.key('url').url;
|
|
60
|
+
return this.#serialize([await this.service.checkRecommendation(url)]);
|
|
61
|
+
}
|
|
62
|
+
async edit(frame) {
|
|
63
|
+
const options = new UnsafeData_1.UnsafeData(frame.options);
|
|
64
|
+
const data = new UnsafeData_1.UnsafeData(frame.data);
|
|
65
|
+
const recommendation = data.key('recommendations').index(0);
|
|
66
|
+
const id = options.key('id').string;
|
|
67
|
+
const plain = {
|
|
68
|
+
title: recommendation.optionalKey('title')?.string,
|
|
69
|
+
url: recommendation.optionalKey('url')?.url,
|
|
70
|
+
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean,
|
|
71
|
+
description: recommendation.optionalKey('description')?.nullable.string,
|
|
72
|
+
excerpt: recommendation.optionalKey('excerpt')?.nullable.string,
|
|
73
|
+
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url,
|
|
74
|
+
favicon: recommendation.optionalKey('favicon')?.nullable.url
|
|
75
|
+
};
|
|
76
|
+
return this.#serialize([await this.service.editRecommendation(id, plain)]);
|
|
77
|
+
}
|
|
78
|
+
async destroy(frame) {
|
|
79
|
+
const options = new UnsafeData_1.UnsafeData(frame.options);
|
|
80
|
+
const id = options.key('id').string;
|
|
81
|
+
await this.service.deleteRecommendation(id);
|
|
82
|
+
}
|
|
83
|
+
#stringToOrder(str) {
|
|
84
|
+
if (!str) {
|
|
85
|
+
// Default order
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
field: 'createdAt',
|
|
89
|
+
direction: 'desc'
|
|
90
|
+
}
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
const parts = str.split(',');
|
|
94
|
+
const order = [];
|
|
95
|
+
for (const [index, part] of parts.entries()) {
|
|
96
|
+
const trimmed = part.trim();
|
|
97
|
+
const fieldData = new UnsafeData_1.UnsafeData(trimmed.split(' ')[0].trim(), { field: ['order', index.toString(), 'field'] });
|
|
98
|
+
const directionData = new UnsafeData_1.UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'desc', { field: ['order', index.toString(), 'direction'] });
|
|
99
|
+
const validatedField = fieldData.enum(Object.keys(RecommendationOrderMap));
|
|
100
|
+
const direction = directionData.enum(['asc', 'desc']);
|
|
101
|
+
// Convert 'count.' and camelCase to snake_case
|
|
102
|
+
const field = RecommendationOrderMap[validatedField];
|
|
103
|
+
order.push({
|
|
104
|
+
field,
|
|
105
|
+
direction
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return order;
|
|
109
|
+
}
|
|
110
|
+
async browse(frame) {
|
|
111
|
+
const options = new UnsafeData_1.UnsafeData(frame.options);
|
|
112
|
+
const page = options.optionalKey('page')?.integer ?? 1;
|
|
113
|
+
const limit = options.optionalKey('limit')?.integer ?? 5;
|
|
114
|
+
const include = options.optionalKey('withRelated')?.array.map((item) => {
|
|
115
|
+
return RecommendationIncludesMap[item.enum(Object.keys(RecommendationIncludesMap))];
|
|
116
|
+
}) ?? [];
|
|
117
|
+
const filter = options.optionalKey('filter')?.string;
|
|
118
|
+
const orderOption = options.optionalKey('order')?.string;
|
|
119
|
+
const order = this.#stringToOrder(orderOption);
|
|
120
|
+
const count = await this.service.countRecommendations({});
|
|
121
|
+
const recommendations = (await this.service.listRecommendations({ page, limit, filter, include, order }));
|
|
122
|
+
return this.#serialize(recommendations, {
|
|
123
|
+
pagination: this.#serializePagination({ page, limit, count })
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async trackClicked(frame) {
|
|
127
|
+
const member = this.#optionalAuthMember(frame);
|
|
128
|
+
const options = new UnsafeData_1.UnsafeData(frame.options);
|
|
129
|
+
const id = options.key('id').string;
|
|
130
|
+
await this.service.trackClicked({
|
|
131
|
+
id,
|
|
132
|
+
memberId: member?.id
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async trackSubscribed(frame) {
|
|
136
|
+
const member = this.#authMember(frame);
|
|
137
|
+
const options = new UnsafeData_1.UnsafeData(frame.options);
|
|
138
|
+
const id = options.key('id').string;
|
|
139
|
+
await this.service.trackSubscribed({
|
|
140
|
+
id,
|
|
141
|
+
memberId: member.id
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
#authMember(frame) {
|
|
145
|
+
const options = new UnsafeData_1.UnsafeData(frame.options);
|
|
146
|
+
const memberId = options.key('context').optionalKey('member')?.nullable.key('id').string;
|
|
147
|
+
if (!memberId) {
|
|
148
|
+
// This is an internal server error because authentication should happen outside this service.
|
|
149
|
+
throw new errors_1.default.UnauthorizedError({
|
|
150
|
+
message: 'Member not found'
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
id: memberId
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
#optionalAuthMember(frame) {
|
|
158
|
+
try {
|
|
159
|
+
const member = this.#authMember(frame);
|
|
160
|
+
return member;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
if (e instanceof errors_1.default.UnauthorizedError) {
|
|
164
|
+
// This is fine, this is not required
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
#serialize(recommendations, meta) {
|
|
173
|
+
return {
|
|
174
|
+
data: recommendations.map((entity) => {
|
|
175
|
+
const d = {
|
|
176
|
+
id: entity.id ?? null,
|
|
177
|
+
title: entity.title ?? null,
|
|
178
|
+
description: entity.description ?? null,
|
|
179
|
+
excerpt: entity.excerpt ?? null,
|
|
180
|
+
featured_image: entity.featuredImage?.toString() ?? null,
|
|
181
|
+
favicon: entity.favicon?.toString() ?? null,
|
|
182
|
+
url: entity.url?.toString() ?? null,
|
|
183
|
+
one_click_subscribe: entity.oneClickSubscribe ?? null,
|
|
184
|
+
created_at: entity.createdAt?.toISOString() ?? null,
|
|
185
|
+
updated_at: entity.updatedAt?.toISOString() ?? null,
|
|
186
|
+
count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? {
|
|
187
|
+
clicks: entity.clickCount,
|
|
188
|
+
subscribers: entity.subscriberCount
|
|
189
|
+
} : undefined
|
|
190
|
+
};
|
|
191
|
+
return d;
|
|
192
|
+
}),
|
|
193
|
+
meta
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
#serializePagination({ page, limit, count }) {
|
|
197
|
+
const pages = Math.ceil(count / limit);
|
|
198
|
+
return {
|
|
199
|
+
page,
|
|
200
|
+
limit,
|
|
201
|
+
total: count,
|
|
202
|
+
pages,
|
|
203
|
+
prev: page > 1 ? page - 1 : null,
|
|
204
|
+
next: page < pages ? page + 1 : null
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
exports.RecommendationController = RecommendationController;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import errors from '@tryghost/errors';
|
|
3
|
+
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
|
|
4
|
+
import {RecommendationService} from './RecommendationService';
|
|
5
|
+
import {UnsafeData} from './UnsafeData';
|
|
6
|
+
import {OrderOption} from './BookshelfRepository';
|
|
7
|
+
|
|
8
|
+
type Frame = {
|
|
9
|
+
data: unknown,
|
|
10
|
+
options: unknown,
|
|
11
|
+
user: unknown,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const RecommendationIncludesMap = {
|
|
15
|
+
'count.clicks': 'clickCount' as const,
|
|
16
|
+
'count.subscribers': 'subscriberCount' as const
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const RecommendationOrderMap = {
|
|
20
|
+
title: 'title' as const,
|
|
21
|
+
description: 'description' as const,
|
|
22
|
+
excerpt: 'excerpt' as const,
|
|
23
|
+
one_click_subscribe: 'oneClickSubscribe' as const,
|
|
24
|
+
created_at: 'createdAt' as const,
|
|
25
|
+
updated_at: 'updatedAt' as const,
|
|
26
|
+
'count.clicks': 'clickCount' as const,
|
|
27
|
+
'count.subscribers': 'subscriberCount' as const
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class RecommendationController {
|
|
31
|
+
service: RecommendationService;
|
|
32
|
+
|
|
33
|
+
constructor(deps: {service: RecommendationService}) {
|
|
34
|
+
this.service = deps.service;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async read(frame: Frame) {
|
|
38
|
+
const options = new UnsafeData(frame.options);
|
|
39
|
+
const id = options.key('id').string;
|
|
40
|
+
|
|
41
|
+
const recommendation = await this.service.readRecommendation(id);
|
|
42
|
+
|
|
43
|
+
return this.#serialize(
|
|
44
|
+
[recommendation]
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async add(frame: Frame) {
|
|
49
|
+
const data = new UnsafeData(frame.data);
|
|
50
|
+
const recommendation = data.key('recommendations').index(0);
|
|
51
|
+
const plain: AddRecommendation = {
|
|
52
|
+
title: recommendation.key('title').string,
|
|
53
|
+
url: recommendation.key('url').url,
|
|
54
|
+
|
|
55
|
+
// Optional fields
|
|
56
|
+
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean ?? false,
|
|
57
|
+
description: recommendation.optionalKey('description')?.nullable.string ?? null,
|
|
58
|
+
excerpt: recommendation.optionalKey('excerpt')?.nullable.string ?? null,
|
|
59
|
+
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url ?? null,
|
|
60
|
+
favicon: recommendation.optionalKey('favicon')?.nullable.url ?? null
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return this.#serialize(
|
|
64
|
+
[await this.service.addRecommendation(plain)]
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Given a recommendation URL, returns either an existing recommendation with that url and updated metadata,
|
|
70
|
+
* or the metadata from that URL as if it would create a new one (without creating a new one)
|
|
71
|
+
*
|
|
72
|
+
* This can be used in the frontend when creating a new recommendation (duplication checking + showing a preview before saving)
|
|
73
|
+
*/
|
|
74
|
+
async check(frame: Frame) {
|
|
75
|
+
const data = new UnsafeData(frame.data);
|
|
76
|
+
const recommendation = data.key('recommendations').index(0);
|
|
77
|
+
const url = recommendation.key('url').url;
|
|
78
|
+
|
|
79
|
+
return this.#serialize(
|
|
80
|
+
[await this.service.checkRecommendation(url)]
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async edit(frame: Frame) {
|
|
85
|
+
const options = new UnsafeData(frame.options);
|
|
86
|
+
const data = new UnsafeData(frame.data);
|
|
87
|
+
const recommendation = data.key('recommendations').index(0);
|
|
88
|
+
|
|
89
|
+
const id = options.key('id').string;
|
|
90
|
+
const plain: Partial<RecommendationPlain> = {
|
|
91
|
+
title: recommendation.optionalKey('title')?.string,
|
|
92
|
+
url: recommendation.optionalKey('url')?.url,
|
|
93
|
+
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean,
|
|
94
|
+
description: recommendation.optionalKey('description')?.nullable.string,
|
|
95
|
+
excerpt: recommendation.optionalKey('excerpt')?.nullable.string,
|
|
96
|
+
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url,
|
|
97
|
+
favicon: recommendation.optionalKey('favicon')?.nullable.url
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return this.#serialize(
|
|
101
|
+
[await this.service.editRecommendation(id, plain)]
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async destroy(frame: Frame) {
|
|
106
|
+
const options = new UnsafeData(frame.options);
|
|
107
|
+
const id = options.key('id').string;
|
|
108
|
+
await this.service.deleteRecommendation(id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#stringToOrder(str?: string) {
|
|
112
|
+
if (!str) {
|
|
113
|
+
// Default order
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
field: 'createdAt' as const,
|
|
117
|
+
direction: 'desc' as const
|
|
118
|
+
}
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parts = str.split(',');
|
|
123
|
+
const order: OrderOption<Recommendation> = [];
|
|
124
|
+
for (const [index, part] of parts.entries()) {
|
|
125
|
+
const trimmed = part.trim();
|
|
126
|
+
const fieldData = new UnsafeData(trimmed.split(' ')[0].trim(), {field: ['order', index.toString(), 'field']});
|
|
127
|
+
const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'desc', {field: ['order', index.toString(), 'direction']});
|
|
128
|
+
|
|
129
|
+
const validatedField = fieldData.enum(
|
|
130
|
+
Object.keys(RecommendationOrderMap) as (keyof typeof RecommendationOrderMap)[]
|
|
131
|
+
);
|
|
132
|
+
const direction = directionData.enum(['asc' as const, 'desc' as const]);
|
|
133
|
+
|
|
134
|
+
// Convert 'count.' and camelCase to snake_case
|
|
135
|
+
const field = RecommendationOrderMap[validatedField];
|
|
136
|
+
order.push({
|
|
137
|
+
field,
|
|
138
|
+
direction
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return order;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async browse(frame: Frame) {
|
|
146
|
+
const options = new UnsafeData(frame.options);
|
|
147
|
+
|
|
148
|
+
const page = options.optionalKey('page')?.integer ?? 1;
|
|
149
|
+
const limit = options.optionalKey('limit')?.integer ?? 5;
|
|
150
|
+
const include = options.optionalKey('withRelated')?.array.map((item) => {
|
|
151
|
+
return RecommendationIncludesMap[item.enum(
|
|
152
|
+
Object.keys(RecommendationIncludesMap) as (keyof typeof RecommendationIncludesMap)[]
|
|
153
|
+
)];
|
|
154
|
+
}) ?? [];
|
|
155
|
+
const filter = options.optionalKey('filter')?.string;
|
|
156
|
+
|
|
157
|
+
const orderOption = options.optionalKey('order')?.string;
|
|
158
|
+
const order = this.#stringToOrder(orderOption);
|
|
159
|
+
|
|
160
|
+
const count = await this.service.countRecommendations({});
|
|
161
|
+
const recommendations = (await this.service.listRecommendations({page, limit, filter, include, order}));
|
|
162
|
+
|
|
163
|
+
return this.#serialize(
|
|
164
|
+
recommendations,
|
|
165
|
+
{
|
|
166
|
+
pagination: this.#serializePagination({page, limit, count})
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async trackClicked(frame: Frame) {
|
|
172
|
+
const member = this.#optionalAuthMember(frame);
|
|
173
|
+
const options = new UnsafeData(frame.options);
|
|
174
|
+
const id = options.key('id').string;
|
|
175
|
+
|
|
176
|
+
await this.service.trackClicked({
|
|
177
|
+
id,
|
|
178
|
+
memberId: member?.id
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async trackSubscribed(frame: Frame) {
|
|
182
|
+
const member = this.#authMember(frame);
|
|
183
|
+
const options = new UnsafeData(frame.options);
|
|
184
|
+
const id = options.key('id').string;
|
|
185
|
+
|
|
186
|
+
await this.service.trackSubscribed({
|
|
187
|
+
id,
|
|
188
|
+
memberId: member.id
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#authMember(frame: Frame): {id: string} {
|
|
193
|
+
const options = new UnsafeData(frame.options);
|
|
194
|
+
const memberId = options.key('context').optionalKey('member')?.nullable.key('id').string;
|
|
195
|
+
if (!memberId) {
|
|
196
|
+
// This is an internal server error because authentication should happen outside this service.
|
|
197
|
+
throw new errors.UnauthorizedError({
|
|
198
|
+
message: 'Member not found'
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
id: memberId
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#optionalAuthMember(frame: Frame): {id: string}|null {
|
|
207
|
+
try {
|
|
208
|
+
const member = this.#authMember(frame);
|
|
209
|
+
return member;
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (e instanceof errors.UnauthorizedError) {
|
|
212
|
+
// This is fine, this is not required
|
|
213
|
+
} else {
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#serialize(recommendations: Partial<RecommendationPlain>[], meta?: any) {
|
|
221
|
+
return {
|
|
222
|
+
data: recommendations.map((entity) => {
|
|
223
|
+
const d = {
|
|
224
|
+
id: entity.id ?? null,
|
|
225
|
+
title: entity.title ?? null,
|
|
226
|
+
description: entity.description ?? null,
|
|
227
|
+
excerpt: entity.excerpt ?? null,
|
|
228
|
+
featured_image: entity.featuredImage?.toString() ?? null,
|
|
229
|
+
favicon: entity.favicon?.toString() ?? null,
|
|
230
|
+
url: entity.url?.toString() ?? null,
|
|
231
|
+
one_click_subscribe: entity.oneClickSubscribe ?? null,
|
|
232
|
+
created_at: entity.createdAt?.toISOString() ?? null,
|
|
233
|
+
updated_at: entity.updatedAt?.toISOString() ?? null,
|
|
234
|
+
count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? {
|
|
235
|
+
clicks: entity.clickCount,
|
|
236
|
+
subscribers: entity.subscriberCount
|
|
237
|
+
} : undefined
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return d;
|
|
241
|
+
}),
|
|
242
|
+
meta
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#serializePagination({page, limit, count}: {page: number, limit: number, count: number}) {
|
|
247
|
+
const pages = Math.ceil(count / limit);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
page,
|
|
251
|
+
limit,
|
|
252
|
+
total: count,
|
|
253
|
+
pages,
|
|
254
|
+
prev: page > 1 ? page - 1 : null,
|
|
255
|
+
next: page < pages ? page + 1 : null
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|