• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hicommonwealth / commonwealth / 14450698512

14 Apr 2025 04:24PM UTC coverage: 46.172% (-0.1%) from 46.287%
14450698512

push

github

web-flow
Merge pull request #11828 from hicommonwealth/tim/spam-indexes

1618 of 3866 branches covered (41.85%)

Branch coverage included in aggregate %.

11 of 19 new or added lines in 4 files covered. (57.89%)

27 existing lines in 6 files now uncovered.

2966 of 6062 relevant lines covered (48.93%)

39.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.0
/libs/model/src/aggregates/thread/UpdateThread.command.ts
1
import {
2
  Actor,
3
  InvalidActor,
4
  InvalidInput,
5
  type Command,
6
} from '@hicommonwealth/core';
7
import * as schemas from '@hicommonwealth/schemas';
8
import { Op } from 'sequelize';
9
import { z } from 'zod';
10
import { models } from '../../database';
11
import { authThread } from '../../middleware';
12
import { mustBeAuthorizedThread, mustExist } from '../../middleware/guards';
13
import {
14
  ThreadAttributes,
15
  ThreadInstance,
16
  getThreadSearchVector,
17
} from '../../models/thread';
18
import {
19
  decodeContent,
20
  emitMentions,
21
  findMentionDiff,
22
  parseUserMentions,
23
  uploadIfLarge,
24
} from '../../utils';
25

26
export const UpdateThreadErrors = {
32✔
27
  ThreadNotFound: 'Thread not found',
28
  InvalidStage: 'Please Select a Stage',
29
  MissingCollaborators: 'Failed to find all provided collaborators',
30
  CollaboratorsOverlap:
31
    'Cannot overlap addresses when adding/removing collaborators',
32
  ContestLock: 'Cannot edit thread that is in a contest',
33
};
34

35
function getContentPatch(
36
  thread: ThreadInstance,
37
  {
38
    title,
39
    body,
40
    url,
41
    canvas_msg_id,
42
    canvas_signed_data,
43
  }: z.infer<typeof schemas.UpdateThread.input>,
44
) {
45
  const patch: Partial<ThreadAttributes> = {};
13✔
46

47
  typeof title !== 'undefined' && (patch.title = title);
13✔
48

49
  if (typeof body !== 'undefined' && thread.kind === 'discussion') {
13✔
50
    patch.body = decodeContent(body);
2✔
51
  }
52

53
  typeof url !== 'undefined' && thread.kind === 'link' && (patch.url = url);
13!
54

55
  if (Object.keys(patch).length > 0) {
13✔
56
    patch.canvas_msg_id = canvas_msg_id;
2✔
57
    patch.canvas_signed_data = canvas_signed_data;
2✔
58
  }
59
  return patch;
13✔
60
}
61

62
async function getCollaboratorsPatch(
63
  actor: Actor,
64
  context: schemas.ThreadContext,
65
  { collaborators }: z.infer<typeof schemas.UpdateThread.input>,
66
) {
67
  const removeSet = new Set(collaborators?.toRemove ?? []);
10✔
68
  const add = [...new Set(collaborators?.toAdd ?? [])];
10✔
69
  const remove = [...removeSet];
10✔
70
  const intersection = add.filter((item) => removeSet.has(item));
10✔
71

72
  if (intersection.length > 0)
10✔
73
    throw new InvalidInput(UpdateThreadErrors.CollaboratorsOverlap);
1✔
74

75
  if (add.length > 0) {
9✔
76
    const addresses = await models.Address.findAll({
2✔
77
      where: {
78
        community_id: context.community_id!,
79
        id: {
80
          [Op.in]: add,
81
        },
82
      },
83
    });
84
    if (addresses.length !== add.length)
2✔
85
      throw new InvalidInput(UpdateThreadErrors.MissingCollaborators);
1✔
86
  }
87

88
  if (add.length > 0 || remove.length > 0) {
8✔
89
    const authorized = actor.user.isAdmin || context.is_author;
3✔
90
    if (!authorized)
3✔
91
      throw new InvalidActor(actor, 'Must be super admin or author');
1✔
92
  }
93

94
  return { add, remove };
7✔
95
}
96

97
function getAdminOrModeratorPatch(
98
  actor: Actor,
99
  context: schemas.ThreadContext,
100
  { pinned, spam }: z.infer<typeof schemas.UpdateThread.input>,
101
) {
102
  const patch: Partial<ThreadAttributes> = {};
13✔
103

104
  typeof pinned !== 'undefined' && (patch.pinned = pinned);
13✔
105

106
  if (typeof spam !== 'undefined') {
13✔
107
    if (spam) {
2✔
108
      patch.marked_as_spam_at = new Date();
1✔
109
      patch.search = null;
1✔
110
    } else if (!spam) {
1!
111
      patch.marked_as_spam_at = null;
1✔
112
    }
113
  }
114

115
  if (Object.keys(patch).length > 0) {
13✔
116
    const authorized =
117
      actor.user.isAdmin ||
2✔
118
      ['admin', 'moderator'].includes(context.address!.role);
119
    if (!authorized)
2✔
120
      throw new InvalidActor(actor, 'Must be admin or moderator');
1✔
121
  }
122
  return patch;
12✔
123
}
124

125
async function getAdminOrModeratorOrOwnerPatch(
126
  actor: Actor,
127
  context: schemas.ThreadContext,
128
  {
129
    locked,
130
    archived,
131
    stage,
132
    topic_id,
133
  }: z.infer<typeof schemas.UpdateThread.input>,
134
) {
135
  const patch: Partial<ThreadAttributes> = {};
12✔
136

137
  if (typeof locked !== 'undefined') {
12✔
138
    patch.read_only = locked;
2✔
139
    patch.locked_at = locked ? new Date() : null;
2✔
140
  }
141

142
  typeof archived !== 'undefined' &&
12✔
143
    (patch.archived_at = archived ? new Date() : null);
2✔
144

145
  if (typeof stage !== 'undefined') {
12✔
146
    const community = await models.Community.findByPk(context.community_id!);
2✔
147
    mustExist('Community', community);
2✔
148

149
    const custom_stages =
150
      community.custom_stages.length > 0
2!
151
        ? community.custom_stages
152
        : ['discussion', 'proposal_in_review', 'voting', 'passed', 'failed'];
153

154
    if (!custom_stages.includes(stage))
2✔
155
      throw new InvalidInput(UpdateThreadErrors.InvalidStage);
1✔
156

157
    patch.stage = stage;
1✔
158
  }
159

160
  if (typeof topic_id !== 'undefined') {
11✔
161
    const topic = await models.Topic.findOne({
1✔
162
      where: { id: topic_id, community_id: context.community_id! },
163
    });
164
    mustExist('Topic', topic);
1✔
165

166
    patch.topic_id = topic_id;
1✔
167
  }
168

169
  if (Object.keys(patch).length > 0) {
11✔
170
    const authorized =
171
      actor.user.isAdmin ||
2✔
172
      ['admin', 'moderator'].includes(context.address!.role) ||
173
      context.is_author;
174
    if (!authorized)
2✔
175
      throw new InvalidActor(actor, 'Must be admin, moderator, or author');
1✔
176
  }
177
  return patch;
10✔
178
}
179

180
export function UpdateThread(): Command<typeof schemas.UpdateThread> {
181
  return {
14✔
182
    ...schemas.UpdateThread,
183
    auth: [authThread({ collaborators: true })],
184
    body: async ({ actor, payload, context }) => {
185
      const { address, thread, thread_id } = mustBeAuthorizedThread(
13✔
186
        actor,
187
        context,
188
      );
189

190
      const content = getContentPatch(thread, payload);
13✔
191
      const adminPatch = getAdminOrModeratorPatch(actor, context!, payload);
13✔
192
      const ownerPatch = await getAdminOrModeratorOrOwnerPatch(
12✔
193
        actor,
194
        context!,
195
        payload,
196
      );
197
      const collaboratorsPatch = await getCollaboratorsPatch(
10✔
198
        actor,
199
        context!,
200
        payload,
201
      );
202

203
      // check if patch violates contest locks
204
      if (
7✔
205
        Object.keys(content).length > 0 ||
19✔
206
        ownerPatch.topic_id ||
207
        collaboratorsPatch.add.length > 0 ||
208
        collaboratorsPatch.remove.length > 0
209
      ) {
210
        const found = await models.ContestManager.findOne({
5✔
211
          where: { topic_id: thread.topic_id! },
212
        });
213
        if (found) throw new InvalidInput(UpdateThreadErrors.ContestLock);
5!
214
      }
215

216
      let contentUrl: string | null = thread.content_url ?? null;
7✔
217
      if (content.body) {
7✔
218
        const result = await uploadIfLarge('threads', content.body);
2✔
219
        contentUrl = result.contentUrl;
2✔
220
      }
221

222
      let newBody = content.body || thread.body || '';
7!
223
      if (
7!
224
        adminPatch.marked_as_spam_at === null &&
7!
225
        !content.body &&
226
        thread.content_url
227
      ) {
NEW
228
        const res = await fetch(thread.content_url);
×
NEW
229
        newBody = await res.text();
×
230
      }
231

232
      // == mutation transaction boundary ==
233
      await models.sequelize.transaction(async (transaction) => {
7✔
234
        const searchUpdate =
235
          content.title || content.body || adminPatch.marked_as_spam_at === null
7✔
236
            ? {
237
                search: getThreadSearchVector(
238
                  content.title || thread.title,
2!
239
                  newBody,
240
                ),
241
              }
242
            : {};
243
        const tokenAddress = payload.launchpad_token_address && {
7✔
244
          launchpad_token_address: payload.launchpad_token_address,
245
        };
246
        await thread.update(
7✔
247
          {
248
            // TODO: body should be set to truncatedBody once client renders content_url
249
            ...content,
250
            ...adminPatch,
251
            ...ownerPatch,
252
            last_edited: new Date(),
253
            ...searchUpdate,
254
            ...tokenAddress,
255
            content_url: contentUrl,
256
            is_linking_token: payload.is_linking_token,
257
          },
258
          { transaction },
259
        );
260

261
        if (collaboratorsPatch.add.length > 0)
7✔
262
          await models.Collaboration.bulkCreate(
1✔
263
            collaboratorsPatch.add.map((address_id) => ({
3✔
264
              address_id,
265
              thread_id,
266
            })),
267
            { transaction },
268
          );
269
        if (collaboratorsPatch.remove.length > 0) {
7✔
270
          await models.Collaboration.destroy({
1✔
271
            where: {
272
              thread_id,
273
              address_id: {
274
                [Op.in]: collaboratorsPatch.remove,
275
              },
276
            },
277
            transaction,
278
          });
279
        }
280

281
        if (content.body) {
7✔
282
          const currentVersion = await models.ThreadVersionHistory.findOne({
2✔
283
            where: { thread_id },
284
            order: [['timestamp', 'DESC']],
285
            transaction,
286
          });
287
          const decodedThreadVersionBody = currentVersion?.body
2!
288
            ? decodeContent(currentVersion?.body)
289
            : '';
290
          // if the modification was different from the original body, create a version history for it
291
          if (decodedThreadVersionBody !== content.body) {
2!
292
            await models.ThreadVersionHistory.create(
2✔
293
              {
294
                thread_id,
295
                address: address.address,
296
                // TODO: body should be set to truncatedBody once client renders content_url
297
                body: content.body,
298
                timestamp: new Date(),
299
                content_url: contentUrl,
300
              },
301
              { transaction },
302
            );
303
            const mentions = findMentionDiff(
2✔
304
              parseUserMentions(decodedThreadVersionBody),
305
              parseUserMentions(content.body),
306
            );
307
            mentions &&
2✔
308
              (await emitMentions(transaction, {
309
                authorAddressId: address.id!,
310
                authorUserId: actor.user.id!,
311
                authorAddress: address.address,
312
                mentions,
313
                thread,
314
                community_id: thread.community_id,
315
              }));
316
          }
317
        }
318
      });
319
      // == end of transaction boundary ==
320

321
      // TODO: should we make a query out of this, or do we have one already?
322
      return (
7✔
323
        await models.Thread.findOne({
324
          where: { id: thread_id },
325
          include: [
326
            {
327
              model: models.Address,
328
              as: 'Address',
329
              include: [
330
                {
331
                  model: models.User,
332
                  required: true,
333
                  attributes: ['id', 'profile', 'tier'],
334
                },
335
              ],
336
            },
337
            {
338
              model: models.Address,
339
              as: 'collaborators',
340
              include: [
341
                {
342
                  model: models.User,
343
                  required: true,
344
                  attributes: ['id', 'profile', 'tier'],
345
                },
346
              ],
347
            },
348
            { model: models.Topic, as: 'topic' },
349
            {
350
              model: models.Reaction,
351
              as: 'reactions',
352
              include: [
353
                {
354
                  model: models.Address,
355
                  required: true,
356
                  include: [
357
                    {
358
                      model: models.User,
359
                      required: true,
360
                      attributes: ['id', 'profile', 'tier'],
361
                    },
362
                  ],
363
                },
364
              ],
365
            },
366
            {
367
              model: models.Comment,
368
              limit: 3, // This could me made configurable, atm we are using 3 recent comments with threads in frontend.
369
              order: [['created_at', 'DESC']],
370
              attributes: [
371
                'id',
372
                'address_id',
373
                'body',
374
                'created_at',
375
                'updated_at',
376
                'deleted_at',
377
                'marked_as_spam_at',
378
                'discord_meta',
379
              ],
380
              include: [
381
                {
382
                  model: models.Address,
383
                  attributes: ['address'],
384
                  include: [
385
                    {
386
                      model: models.User,
387
                      attributes: ['profile', 'tier'],
388
                    },
389
                  ],
390
                },
391
              ],
392
            },
393
            {
394
              model: models.ThreadVersionHistory,
395
            },
396
          ],
397
        })
398
      )?.toJSON();
399
    },
400
  };
401
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc