ghost 5.128.0 → 5.129.0

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.
Files changed (44) hide show
  1. package/components/tryghost-i18n-5.129.0.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +2 -2
  3. package/core/built/admin/assets/admin-x-activitypub/{index-CAyELgjK.mjs → index-Ou5BZ-qx.mjs} +7 -6
  4. package/core/built/admin/assets/admin-x-activitypub/{index-Dw9BlNtX.mjs → index-q-fcRpYq.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-D-2S3E5-.mjs → CodeEditorView-BdOUrhjH.mjs} +2 -2
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  7. package/core/built/admin/assets/admin-x-settings/{index-3ewKySD9.mjs → index-BIcU5l8u.mjs} +105 -105
  8. package/core/built/admin/assets/admin-x-settings/{index-0OLkY3Hc.mjs → index-CyYRbOBi.mjs} +5485 -5307
  9. package/core/built/admin/assets/admin-x-settings/{modals-viB8Wk3n.mjs → modals-r5LXiWii.mjs} +8221 -8196
  10. package/core/built/admin/assets/{chunk.524.293f624425ff9f9e45d1.js → chunk.524.59a57ec279b535711b3f.js} +5 -5
  11. package/core/built/admin/assets/{chunk.582.20f1821e20aec8197bfc.js → chunk.582.324905279ae25a297c8e.js} +9 -9
  12. package/core/built/admin/assets/{ghost-edcc476760aeb9eaddd24341ec19f261.js → ghost-29e715d82674921e3f545fd4778b09ac.js} +375 -345
  13. package/core/built/admin/assets/{ghost-673f1a892cb83c3441c2ad448d4b634d.css → ghost-415f8e3c36dbe0e09f87608628da382d.css} +1 -1
  14. package/core/built/admin/assets/{ghost-dark-1e5d92b26a08ecf6f98999c53102f221.css → ghost-dark-2043bca95512f1fa2ff0bea2f8a632b0.css} +1 -1
  15. package/core/built/admin/assets/posts/posts.js +63002 -62842
  16. package/core/built/admin/assets/stats/stats.js +32122 -31899
  17. package/core/built/admin/assets/{vendor-9631e49a9ba32620e8fdc561f1380348.js → vendor-c89102f24c3d9502e9db741509767580.js} +20 -18
  18. package/core/built/admin/index.html +5 -5
  19. package/core/frontend/helpers/ghost_head.js +4 -2
  20. package/core/frontend/public/ghost-stats.min.js +1 -1
  21. package/core/frontend/src/ghost-stats/ghost-stats.js +9 -5
  22. package/core/server/api/endpoints/comments.js +86 -0
  23. package/core/server/api/endpoints/index.js +5 -1
  24. package/core/server/api/endpoints/search-index-public.js +26 -4
  25. package/core/server/api/endpoints/search-index.js +84 -0
  26. package/core/server/api/endpoints/utils/serializers/output/search-index.js +79 -18
  27. package/core/server/data/schema/schema.js +1 -1
  28. package/core/server/data/seeders/importers/TagsImporter.js +2 -2
  29. package/core/server/services/comments/CommentsService.js +21 -7
  30. package/core/server/services/email-service/email-templates/partials/styles.hbs +1 -0
  31. package/core/server/services/media-inliner/ExternalMediaInliner.js +21 -2
  32. package/core/server/services/permissions/can-this.js +10 -3
  33. package/core/server/services/stats/PostsStatsService.js +52 -0
  34. package/core/server/web/api/endpoints/admin/middleware.js +5 -3
  35. package/core/server/web/api/endpoints/admin/routes.js +7 -0
  36. package/core/server/web/api/endpoints/content/routes.js +3 -3
  37. package/core/shared/labs.js +1 -2
  38. package/core/shared/settings-cache/CacheManager.js +1 -0
  39. package/package.json +6 -5
  40. package/tsconfig.tsbuildinfo +1 -1
  41. package/yarn.lock +121 -90
  42. package/components/tryghost-i18n-5.128.0.tgz +0 -0
  43. package/core/server/services/search-index/SearchIndexService.js +0 -43
  44. package/core/server/services/search-index/index.js +0 -8
@@ -1,5 +1,6 @@
1
1
  const models = require('../../models');
2
2
  const commentsService = require('../../services/comments');
3
+ const errors = require('@tryghost/errors');
3
4
  function handleCacheHeaders(model, frame) {
4
5
  if (model) {
5
6
  const postId = model.get('post_id');
@@ -12,6 +13,33 @@ function handleCacheHeaders(model, frame) {
12
13
  }
13
14
  }
14
15
 
16
+ function validateCommentData(data) {
17
+ if (!data.post_id && !data.parent_id) {
18
+ throw new errors.ValidationError({
19
+ message: 'Either post_id (for top-level comments) or parent_id (for replies) must be provided'
20
+ });
21
+ }
22
+ }
23
+
24
+ function validateCreatedAt(createdAt) {
25
+ if (!createdAt) {
26
+ return undefined;
27
+ }
28
+
29
+ // Only accept string or Date objects, reject other types like numbers
30
+ if (typeof createdAt !== 'string' && !(createdAt instanceof Date)) {
31
+ return undefined;
32
+ }
33
+
34
+ const date = new Date(createdAt);
35
+ // Check if the date is valid and not in the future
36
+ if (!isNaN(date.getTime()) && date <= new Date()) {
37
+ return date;
38
+ }
39
+
40
+ return undefined;
41
+ }
42
+
15
43
  /** @type {import('@tryghost/api-framework').Controller} */
16
44
  const controller = {
17
45
  docName: 'comments',
@@ -67,6 +95,64 @@ const controller = {
67
95
  permissions: true,
68
96
  async query(frame) {
69
97
  const result = await commentsService.controller.adminBrowse(frame);
98
+ return result;
99
+ }
100
+ },
101
+ add: {
102
+ statusCode: 201,
103
+ headers: {
104
+ cacheInvalidate: false
105
+ },
106
+ options: [
107
+ 'include'
108
+ ],
109
+ validation: {
110
+ options: {
111
+ include: {
112
+ values: ['post', 'member', 'replies', 'replies.member']
113
+ }
114
+ },
115
+ data: {
116
+ member_id: {
117
+ required: true
118
+ },
119
+ html: {
120
+ required: true
121
+ }
122
+ }
123
+ },
124
+ permissions: true,
125
+ async query(frame) {
126
+ const data = frame.data.comments[0];
127
+
128
+ validateCommentData(data);
129
+ const validatedCreatedAt = validateCreatedAt(data.created_at);
130
+
131
+ // Set internal context to prevent notifications
132
+ if (!frame.options.context) {
133
+ frame.options.context = {};
134
+ }
135
+ frame.options.context.internal = true;
136
+
137
+ const result = data.parent_id
138
+ ? await commentsService.api.replyToComment(
139
+ data.parent_id,
140
+ data.in_reply_to_id,
141
+ data.member_id,
142
+ data.html,
143
+ frame.options,
144
+ validatedCreatedAt
145
+ )
146
+ : await commentsService.api.commentOnPost(
147
+ data.post_id,
148
+ data.member_id,
149
+ data.html,
150
+ frame.options,
151
+ validatedCreatedAt
152
+ );
153
+
154
+ handleCacheHeaders(result, frame);
155
+
70
156
  return result;
71
157
  }
72
158
  }
@@ -217,6 +217,10 @@ module.exports = {
217
217
  return apiFramework.pipeline(require('./incoming-recommendations'), localUtils);
218
218
  },
219
219
 
220
+ get searchIndex() {
221
+ return apiFramework.pipeline(require('./search-index'), localUtils);
222
+ },
223
+
220
224
  /**
221
225
  * Content API Controllers
222
226
  *
@@ -269,7 +273,7 @@ module.exports = {
269
273
  return apiFramework.pipeline(require('./recommendations-public'), localUtils, 'content');
270
274
  },
271
275
 
272
- get searchIndex() {
276
+ get searchIndexPublic() {
273
277
  return apiFramework.pipeline(require('./search-index-public'), localUtils, 'content');
274
278
  }
275
279
  };
@@ -1,4 +1,6 @@
1
- const searchIndexService = require('../../services/search-index');
1
+ const models = require('../../models');
2
+ const getPostServiceInstance = require('../../services/posts/posts-service');
3
+ const postsService = getPostServiceInstance();
2
4
 
3
5
  /** @type {import('@tryghost/api-framework').Controller} */
4
6
  const controller = {
@@ -9,7 +11,14 @@ const controller = {
9
11
  },
10
12
  permissions: true,
11
13
  query() {
12
- return searchIndexService.fetchPosts();
14
+ const options = {
15
+ filter: 'type:post',
16
+ limit: '10000',
17
+ order: 'updated_at DESC',
18
+ columns: ['id', 'slug', 'title', 'excerpt', 'url', 'updated_at', 'visibility']
19
+ };
20
+
21
+ return postsService.browsePosts(options);
13
22
  }
14
23
  },
15
24
  fetchAuthors: {
@@ -18,7 +27,13 @@ const controller = {
18
27
  },
19
28
  permissions: true,
20
29
  query() {
21
- return searchIndexService.fetchAuthors();
30
+ const options = {
31
+ limit: '10000',
32
+ order: 'updated_at DESC',
33
+ columns: ['id', 'slug', 'name', 'url', 'profile_image']
34
+ };
35
+
36
+ return models.Author.findPage(options);
22
37
  }
23
38
  },
24
39
  fetchTags: {
@@ -27,7 +42,14 @@ const controller = {
27
42
  },
28
43
  permissions: true,
29
44
  query() {
30
- return searchIndexService.fetchTags();
45
+ const options = {
46
+ limit: '10000',
47
+ order: 'updated_at DESC',
48
+ columns: ['id', 'slug', 'name', 'url'],
49
+ filter: 'visibility:public'
50
+ };
51
+
52
+ return models.Tag.findPage(options);
31
53
  }
32
54
  }
33
55
  };
@@ -0,0 +1,84 @@
1
+ const models = require('../../models');
2
+ const getPostServiceInstance = require('../../services/posts/posts-service');
3
+ const postsService = getPostServiceInstance();
4
+
5
+ /** @type {import('@tryghost/api-framework').Controller} */
6
+ const controller = {
7
+ docName: 'search_index',
8
+ fetchPosts: {
9
+ headers: {
10
+ cacheInvalidate: false
11
+ },
12
+ permissions: {
13
+ docName: 'posts',
14
+ method: 'browse'
15
+ },
16
+ query() {
17
+ const options = {
18
+ filter: 'type:post',
19
+ limit: '10000',
20
+ order: 'updated_at DESC',
21
+ columns: ['id', 'url', 'title', 'status', 'published_at', 'visibility']
22
+ };
23
+
24
+ return postsService.browsePosts(options);
25
+ }
26
+ },
27
+ fetchPages: {
28
+ headers: {
29
+ cacheInvalidate: false
30
+ },
31
+ permissions: {
32
+ docName: 'posts',
33
+ method: 'browse'
34
+ },
35
+ query() {
36
+ const options = {
37
+ filter: 'type:page',
38
+ limit: '10000',
39
+ order: 'updated_at DESC',
40
+ columns: ['id', 'url', 'title', 'status', 'published_at', 'visibility']
41
+ };
42
+
43
+ return postsService.browsePosts(options);
44
+ }
45
+ },
46
+ fetchTags: {
47
+ headers: {
48
+ cacheInvalidate: false
49
+ },
50
+ permissions: {
51
+ docName: 'tags',
52
+ method: 'browse'
53
+ },
54
+ query() {
55
+ const options = {
56
+ limit: '10000',
57
+ order: 'updated_at DESC',
58
+ columns: ['id', 'slug', 'name', 'url']
59
+ };
60
+
61
+ return models.Tag.findPage(options);
62
+ }
63
+ },
64
+ fetchUsers: {
65
+ headers: {
66
+ cacheInvalidate: false
67
+ },
68
+ permissions: {
69
+ docName: 'users',
70
+ method: 'browse'
71
+ },
72
+ query() {
73
+ const options = {
74
+ limit: '10000',
75
+ order: 'updated_at DESC',
76
+ columns: ['id', 'slug', 'url', 'name', 'profile_image']
77
+ };
78
+
79
+ return models.User.findPage(options);
80
+ }
81
+ }
82
+ };
83
+
84
+ module.exports = controller;
@@ -1,33 +1,93 @@
1
1
  const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output:search-index');
2
- const mappers = require('./mappers');
3
2
  const _ = require('lodash');
4
3
 
4
+ const mappers = require('./mappers');
5
+ const utils = require('../../index');
6
+
5
7
  module.exports = {
6
8
  async fetchPosts(models, apiConfig, frame) {
7
9
  debug('fetchPosts');
8
10
 
9
11
  let posts = [];
12
+ let keys = [];
13
+
14
+ if (utils.isContentAPI(frame)) {
15
+ keys.push(
16
+ 'id',
17
+ 'slug',
18
+ 'title',
19
+ 'excerpt',
20
+ 'url',
21
+ 'updated_at',
22
+ 'visibility'
23
+ );
24
+ } else {
25
+ keys.push(
26
+ 'id',
27
+ 'url',
28
+ 'title',
29
+ 'status',
30
+ 'published_at',
31
+ 'visibility'
32
+ );
33
+ }
34
+
35
+ for (let model of models.data) {
36
+ let post = await mappers.posts(model, frame, {});
37
+ post = _.pick(post, keys);
38
+ posts.push(post);
39
+ }
40
+
41
+ frame.response = {
42
+ posts
43
+ };
44
+ },
45
+
46
+ async fetchPages(models, apiConfig, frame) {
47
+ debug('fetchPages');
48
+
49
+ let pages = [];
10
50
 
11
51
  const keys = [
12
52
  'id',
13
- 'slug',
14
- 'title',
15
- 'excerpt',
16
53
  'url',
17
- 'created_at',
18
- 'updated_at',
54
+ 'title',
55
+ 'status',
19
56
  'published_at',
20
57
  'visibility'
21
58
  ];
22
59
 
23
60
  for (let model of models.data) {
24
- let post = await mappers.posts(model, frame, {});
25
- post = _.pick(post, keys);
26
- posts.push(post);
61
+ let page = await mappers.pages(model, frame, {});
62
+ page = _.pick(page, keys);
63
+ pages.push(page);
27
64
  }
28
65
 
29
66
  frame.response = {
30
- posts
67
+ pages
68
+ };
69
+ },
70
+
71
+ async fetchTags(models, apiConfig, frame) {
72
+ debug('fetchTags');
73
+
74
+ let tags = [];
75
+
76
+ const keys = [
77
+ 'id',
78
+ 'slug',
79
+ 'name',
80
+ 'url'
81
+ ];
82
+
83
+ for (let model of models.data) {
84
+ let tag = await mappers.tags(model, frame);
85
+ tag = _.pick(tag, keys);
86
+ tags.push(tag);
87
+ }
88
+
89
+ frame.response = {
90
+ tags
31
91
  };
32
92
  },
33
93
 
@@ -55,26 +115,27 @@ module.exports = {
55
115
  };
56
116
  },
57
117
 
58
- async fetchTags(models, apiConfig, frame) {
59
- debug('fetchTags');
118
+ async fetchUsers(models, apiConfig, frame) {
119
+ debug('fetchUsers');
60
120
 
61
- let tags = [];
121
+ let users = [];
62
122
 
63
123
  const keys = [
64
124
  'id',
65
125
  'slug',
66
126
  'name',
67
- 'url'
127
+ 'url',
128
+ 'profile_image'
68
129
  ];
69
130
 
70
131
  for (let model of models.data) {
71
- let tag = await mappers.tags(model, frame);
72
- tag = _.pick(tag, keys);
73
- tags.push(tag);
132
+ let user = await mappers.users(model, frame);
133
+ user = _.pick(user, keys);
134
+ users.push(user);
74
135
  }
75
136
 
76
137
  frame.response = {
77
- tags
138
+ users
78
139
  };
79
140
  }
80
141
  };
@@ -135,7 +135,7 @@ module.exports = {
135
135
  email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
136
136
  profile_image: {type: 'string', maxlength: 2000, nullable: true},
137
137
  cover_image: {type: 'string', maxlength: 2000, nullable: true},
138
- bio: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 200}}},
138
+ bio: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 250}}},
139
139
  website: {type: 'string', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}},
140
140
  location: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 150}}},
141
141
  facebook: {type: 'string', maxlength: 2000, nullable: true},
@@ -21,7 +21,7 @@ class TagsImporter extends TableImporter {
21
21
  }
22
22
 
23
23
  generate() {
24
- let name = `${faker.color.human()} ${faker.name.jobType()}`;
24
+ let name = `${faker.color.human()} ${faker.name.jobType()} ${faker.random.numeric(3)}`;
25
25
  name = `${name[0].toUpperCase()}${name.slice(1)}`;
26
26
  const threeYearsAgo = new Date();
27
27
  threeYearsAgo.setFullYear(threeYearsAgo.getFullYear() - 3);
@@ -30,7 +30,7 @@ class TagsImporter extends TableImporter {
30
30
  return {
31
31
  id: this.fastFakeObjectId(),
32
32
  name: name,
33
- slug: `${slugify(name)}-${faker.random.numeric(3)}`,
33
+ slug: slugify(name),
34
34
  description: faker.lorem.sentence(),
35
35
  created_at: dateToDatabaseString(faker.date.between(threeYearsAgo, twoYearsAgo)),
36
36
  created_by: this.users[faker.datatype.number(this.users.length - 1)].id
@@ -204,7 +204,7 @@ class CommentsService {
204
204
 
205
205
  if (!model) {
206
206
  throw new errors.NotFoundError({
207
- messages: tpl(messages.commentNotFound)
207
+ message: tpl(messages.commentNotFound)
208
208
  });
209
209
  }
210
210
 
@@ -216,8 +216,9 @@ class CommentsService {
216
216
  * @param {string} member - The ID of the Member to comment as
217
217
  * @param {string} comment - The HTML content of the Comment
218
218
  * @param {any} options
219
+ * @param {Date} [createdAt] - Optional custom created_at timestamp
219
220
  */
220
- async commentOnPost(post, member, comment, options) {
221
+ async commentOnPost(post, member, comment, options, createdAt) {
221
222
  this.checkEnabled();
222
223
  const memberModel = await this.models.Member.findOne({
223
224
  id: member
@@ -239,13 +240,19 @@ class CommentsService {
239
240
 
240
241
  this.checkPostAccess(postModel, memberModel);
241
242
 
242
- const model = await this.models.Comment.add({
243
+ const commentData = {
243
244
  post_id: post,
244
245
  member_id: member,
245
246
  parent_id: null,
246
247
  html: comment,
247
248
  status: 'published'
248
- }, options);
249
+ };
250
+
251
+ if (createdAt) {
252
+ commentData.created_at = createdAt;
253
+ }
254
+
255
+ const model = await this.models.Comment.add(commentData, options);
249
256
 
250
257
  if (!options.context.internal) {
251
258
  await this.sendNewCommentNotifications(model);
@@ -267,8 +274,9 @@ class CommentsService {
267
274
  * @param {string} member - The ID of the Member to comment as
268
275
  * @param {string} comment - The HTML content of the Comment
269
276
  * @param {any} options
277
+ * @param {Date} [createdAt] - Optional custom created_at timestamp
270
278
  */
271
- async replyToComment(parent, inReplyTo, member, comment, options) {
279
+ async replyToComment(parent, inReplyTo, member, comment, options, createdAt) {
272
280
  this.checkEnabled();
273
281
  const memberModel = await this.models.Member.findOne({
274
282
  id: member
@@ -319,14 +327,20 @@ class CommentsService {
319
327
  }
320
328
  }
321
329
 
322
- const model = await this.models.Comment.add({
330
+ const commentData = {
323
331
  post_id: parentComment.get('post_id'),
324
332
  member_id: member,
325
333
  parent_id: parentComment.id,
326
334
  in_reply_to_id: inReplyToComment && inReplyToComment.get('id'),
327
335
  html: comment,
328
336
  status: 'published'
329
- }, options);
337
+ };
338
+
339
+ if (createdAt) {
340
+ commentData.created_at = createdAt;
341
+ }
342
+
343
+ const model = await this.models.Comment.add(commentData, options);
330
344
 
331
345
  if (!options.context.internal) {
332
346
  await this.sendNewCommentNotifications(model);
@@ -169,6 +169,7 @@ li {
169
169
  margin: 0.5em 0;
170
170
  padding-left: 0.3em;
171
171
  line-height: 1.6em;
172
+ color: {{#if backgroundIsDark}}#ffffff{{else}}#15212A{{/if}};
172
173
  }
173
174
 
174
175
  dt {
@@ -5,6 +5,7 @@ const errors = require('@tryghost/errors');
5
5
  const logging = require('@tryghost/logging');
6
6
  const string = require('@tryghost/string');
7
7
  const path = require('path');
8
+ const convert = require('heic-convert');
8
9
 
9
10
  class ExternalMediaInliner {
10
11
  /** @type {object} */
@@ -76,11 +77,12 @@ class ExternalMediaInliner {
76
77
  */
77
78
  async extractFileDataFromResponse(requestURL, response) {
78
79
  let extension;
80
+ let body = response.body;
79
81
 
80
82
  // Attempt to get the file extension from the file itself
81
83
  // If that fails, or if `.ext` is undefined, get the extension from the file path in the catch
82
84
  try {
83
- const fileInfo = await FileType.fromBuffer(response.body);
85
+ const fileInfo = await FileType.fromBuffer(body);
84
86
  extension = fileInfo.ext;
85
87
  } catch {
86
88
  const headers = response.headers;
@@ -89,6 +91,23 @@ class ExternalMediaInliner {
89
91
  extension = mime.extension(contentType) || extensionFromPath;
90
92
  }
91
93
 
94
+ // If the file is heic or heif, attempt to convert it to jpeg
95
+ try {
96
+ if (extension === 'heic' || extension === 'heif') {
97
+ body = await convert({
98
+ buffer: body,
99
+ format: 'JPEG'
100
+ });
101
+
102
+ extension = 'jpg';
103
+ }
104
+ } catch (error) {
105
+ logging.error(`Error converting file to JPEG: ${requestURL}`);
106
+ logging.error(new errors.DataImportError({
107
+ err: error
108
+ }));
109
+ }
110
+
92
111
  const removeExtRegExp = new RegExp(`.${extension}`, '');
93
112
  const fileNameNoExt = path.parse(requestURL).base.replace(removeExtRegExp, '');
94
113
 
@@ -100,7 +119,7 @@ class ExternalMediaInliner {
100
119
  }).slice(-248).replace(/^-|-$/, '');
101
120
 
102
121
  return {
103
- fileBuffer: response.body,
122
+ fileBuffer: body,
104
123
  filename: `${fileName}.${extension}`,
105
124
  extension: `.${extension}`
106
125
  };
@@ -81,9 +81,16 @@ class CanThisResult {
81
81
  // Check api key permissions if they were passed
82
82
  hasApiKeyPermission = true;
83
83
  if (!_.isNull(apiKeyPermissions)) {
84
- // api key request have no user, but we want the user permissions checks to pass
85
- hasUserPermission = true;
86
- hasApiKeyPermission = _.some(apiKeyPermissions, checkPermission);
84
+ if (loadedPermissions.user) {
85
+ // Staff API key scenario: both user and API key present
86
+ // Use USER permissions and ignore API key permissions
87
+ hasApiKeyPermission = true; // Allow API key check to pass
88
+ } else {
89
+ // Traditional API key scenario: API key only, no user
90
+ // Use API key permissions as before
91
+ hasUserPermission = true;
92
+ hasApiKeyPermission = _.some(apiKeyPermissions, checkPermission);
93
+ }
87
94
  }
88
95
 
89
96
  // Offer a chance for the TargetModel to override the results
@@ -1193,6 +1193,7 @@ class PostsStatsService {
1193
1193
  'p.title',
1194
1194
  'p.published_at',
1195
1195
  'p.feature_image',
1196
+ 'p.status',
1196
1197
  'emails.email_count',
1197
1198
  'emails.opened_count'
1198
1199
  )
@@ -1203,6 +1204,29 @@ class PostsStatsService {
1203
1204
  .orderBy('p.published_at', 'desc')
1204
1205
  .limit(limit);
1205
1206
 
1207
+ // Get authors for all posts
1208
+ const postIds = posts.map(p => p.post_id);
1209
+ const authorsData = postIds.length > 0 ? await this.knex('posts_authors as pa')
1210
+ .select('pa.post_id', 'u.name', 'pa.sort_order')
1211
+ .leftJoin('users as u', 'u.id', 'pa.author_id')
1212
+ .whereIn('pa.post_id', postIds)
1213
+ .whereNotNull('u.name')
1214
+ .orderBy(['pa.post_id', 'pa.sort_order']) : [];
1215
+
1216
+ // Group authors by post_id
1217
+ const authorsByPost = {};
1218
+ authorsData.forEach((author) => {
1219
+ if (!authorsByPost[author.post_id]) {
1220
+ authorsByPost[author.post_id] = [];
1221
+ }
1222
+ authorsByPost[author.post_id].push(author.name);
1223
+ });
1224
+
1225
+ // Add authors to posts
1226
+ posts.forEach((post) => {
1227
+ post.authors = (authorsByPost[post.post_id] || []).join(', ');
1228
+ });
1229
+
1206
1230
  // Get member attribution counts and click counts for these posts
1207
1231
  const [memberAttributionCounts, clickCounts] = await Promise.all([
1208
1232
  this._getMemberAttributionCounts(posts.map(p => p.post_id), options),
@@ -1227,6 +1251,8 @@ class PostsStatsService {
1227
1251
  title: post.title,
1228
1252
  published_at: post.published_at,
1229
1253
  feature_image: post.feature_image ? urlUtils.transformReadyToAbsolute(post.feature_image) : post.feature_image,
1254
+ status: post.status,
1255
+ authors: post.authors,
1230
1256
  views: row.visits,
1231
1257
  sent_count: post.email_count || null,
1232
1258
  opened_count: post.opened_count || null,
@@ -1257,6 +1283,7 @@ class PostsStatsService {
1257
1283
  'p.title',
1258
1284
  'p.published_at',
1259
1285
  'p.feature_image',
1286
+ 'p.status',
1260
1287
  'emails.email_count',
1261
1288
  'emails.opened_count'
1262
1289
  )
@@ -1268,6 +1295,29 @@ class PostsStatsService {
1268
1295
  .orderBy('p.published_at', 'desc')
1269
1296
  .limit(remainingCount);
1270
1297
 
1298
+ // Get authors for additional posts
1299
+ const additionalPostIds = additionalPosts.map(p => p.post_id);
1300
+ const additionalAuthorsData = additionalPostIds.length > 0 ? await this.knex('posts_authors as pa')
1301
+ .select('pa.post_id', 'u.name', 'pa.sort_order')
1302
+ .leftJoin('users as u', 'u.id', 'pa.author_id')
1303
+ .whereIn('pa.post_id', additionalPostIds)
1304
+ .whereNotNull('u.name')
1305
+ .orderBy(['pa.post_id', 'pa.sort_order']) : [];
1306
+
1307
+ // Group authors by post_id for additional posts
1308
+ const additionalAuthorsByPost = {};
1309
+ additionalAuthorsData.forEach((author) => {
1310
+ if (!additionalAuthorsByPost[author.post_id]) {
1311
+ additionalAuthorsByPost[author.post_id] = [];
1312
+ }
1313
+ additionalAuthorsByPost[author.post_id].push(author.name);
1314
+ });
1315
+
1316
+ // Add authors to additional posts
1317
+ additionalPosts.forEach((post) => {
1318
+ post.authors = (additionalAuthorsByPost[post.post_id] || []).join(', ');
1319
+ });
1320
+
1271
1321
  // Get member attribution counts and click counts for additional posts
1272
1322
  if (additionalPosts.length > 0) {
1273
1323
  [additionalMemberAttributionCounts, additionalClickCounts] = await Promise.all([
@@ -1289,6 +1339,8 @@ class PostsStatsService {
1289
1339
  title: post.title,
1290
1340
  published_at: post.published_at,
1291
1341
  feature_image: post.feature_image ? urlUtils.transformReadyToAbsolute(post.feature_image) : post.feature_image,
1342
+ status: post.status,
1343
+ authors: post.authors,
1292
1344
  views: 0,
1293
1345
  sent_count: post.email_count || null,
1294
1346
  opened_count: post.opened_count || null,