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

hicommonwealth / commonwealth / 13497108514

24 Feb 2025 11:36AM UTC coverage: 46.449% (+0.08%) from 46.365%
13497108514

Pull #11078

github

web-flow
Merge 56f84aad3 into beadf67b7
Pull Request #11078: Improvements to Community Homepages

1317 of 3113 branches covered (42.31%)

Branch coverage included in aggregate %.

2490 of 5083 relevant lines covered (48.99%)

38.03 hits per line

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

51.85
/libs/model/src/thread/CreateThread.command.ts
1
import {
2
  Actor,
3
  AppError,
4
  InvalidInput,
5
  InvalidState,
6
  type Command,
7
} from '@hicommonwealth/core';
8
import * as schemas from '@hicommonwealth/schemas';
9
import { BalanceSourceType } from '@hicommonwealth/shared';
10
import { z } from 'zod';
11
import { config } from '../config';
12
import { GetActiveContestManagers } from '../contest';
13
import { models } from '../database';
14
import { authTopic } from '../middleware';
15
import { verifyThreadSignature } from '../middleware/canvas';
16
import { mustBeAuthorized } from '../middleware/guards';
17
import { getThreadSearchVector } from '../models/thread';
18
import { tokenBalanceCache } from '../services';
19
import {
20
  decodeContent,
21
  emitMentions,
22
  parseUserMentions,
23
  uniqueMentions,
24
  uploadIfLarge,
25
} from '../utils';
26

27
export const CreateThreadErrors = {
29✔
28
  InsufficientTokenBalance: 'Insufficient token balance',
29
  BalanceCheckFailed: 'Could not verify user token balance',
30
  ParseMentionsFailed: 'Failed to parse mentions',
31
  LinkMissingTitleOrUrl: 'Links must include a title and URL',
32
  UnsupportedKind: 'Only discussion and link posts supported',
33
  FailedCreateThread: 'Failed to create thread',
34
  DiscussionMissingTitle: 'Discussion posts must include a title',
35
  NoBody: 'Thread body cannot be blank',
36
  PostLimitReached: 'Post limit reached',
37
  ArchivedTopic: 'Cannot post in archived topic',
38
};
39

40
const getActiveContestManagersQuery = GetActiveContestManagers();
29✔
41

42
/**
43
 * Ensure that user has non-dust ETH value
44
 */
45
async function checkAddressBalance(
46
  activeContestManagers: z.infer<typeof getActiveContestManagersQuery.output>,
47
  address: string,
48
) {
49
  const balances = await tokenBalanceCache.getBalances({
×
50
    balanceSourceType: BalanceSourceType.ETHNative,
51
    addresses: [address],
52
    sourceOptions: {
53
      evmChainId: activeContestManagers[0]!.eth_chain_id,
54
    },
55
    cacheRefresh: true,
56
  });
57
  const minUserEthBigInt = BigInt(config.CONTESTS.MIN_USER_ETH * 1e18);
×
58
  if (BigInt(balances[address]) < minUserEthBigInt)
×
59
    throw new AppError(
×
60
      `user ETH balance insufficient (${balances[address]} of ${minUserEthBigInt})`,
61
    );
62
}
63

64
/**
65
 * Ensure post limit not reached on active contests
66
 */
67
function checkContestLimits(
68
  activeContestManagers: z.infer<typeof getActiveContestManagersQuery.output>,
69
  address: string,
70
) {
71
  const validActiveContests = activeContestManagers.filter((c) => {
×
72
    const userPostsInContest = c.actions.filter(
×
73
      (action) => action.actor_address === address && action.action === 'added',
×
74
    );
75
    const quotaReached =
76
      userPostsInContest.length >= config.CONTESTS.MAX_USER_POSTS_PER_CONTEST;
×
77
    return !quotaReached;
×
78
  });
79
  if (validActiveContests.length === 0)
×
80
    throw new AppError(CreateThreadErrors.PostLimitReached);
×
81
}
82

83
export function CreateThread(): Command<typeof schemas.CreateThread> {
84
  return {
17✔
85
    ...schemas.CreateThread,
86
    auth: [
87
      authTopic({ action: schemas.PermissionEnum.CREATE_THREAD }),
88
      verifyThreadSignature,
89
    ],
90
    body: async ({ actor, payload, context }) => {
91
      const { address } = mustBeAuthorized(actor, context);
10✔
92

93
      const { community_id, topic_id, kind, url, ...rest } = payload;
10✔
94

95
      if (kind === 'link' && !url?.trim())
10!
96
        throw new InvalidInput(CreateThreadErrors.LinkMissingTitleOrUrl);
×
97

98
      const topic = await models.Topic.findOne({ where: { id: topic_id } });
10✔
99
      if (topic?.archived_at)
10!
100
        throw new InvalidState(CreateThreadErrors.ArchivedTopic);
×
101

102
      // check contest invariants
103
      const activeContestManagers = await getActiveContestManagersQuery.body({
10✔
104
        actor: {} as Actor,
105
        payload: {
106
          community_id,
107
          topic_id,
108
        },
109
      });
110
      if (activeContestManagers && activeContestManagers.length > 0) {
10!
111
        await checkAddressBalance(activeContestManagers, actor.address!);
×
112
        checkContestLimits(activeContestManagers, actor.address!);
×
113
      }
114

115
      const body = decodeContent(payload.body);
10✔
116
      const mentions = uniqueMentions(parseUserMentions(body));
10✔
117

118
      const { contentUrl } = await uploadIfLarge('threads', body);
10✔
119

120
      // == mutation transaction boundary ==
121
      const new_thread_id = await models.sequelize.transaction(
10✔
122
        async (transaction) => {
123
          const thread = await models.Thread.create(
10✔
124
            {
125
              ...rest,
126
              community_id,
127
              address_id: address.id!,
128
              topic_id,
129
              kind,
130
              body,
131
              view_count: 0,
132
              comment_count: 0,
133
              reaction_count: 0,
134
              reaction_weights_sum: '0',
135
              search: getThreadSearchVector(rest.title, body),
136
              content_url: contentUrl,
137
            },
138
            {
139
              transaction,
140
            },
141
          );
142

143
          await models.ThreadVersionHistory.create(
10✔
144
            {
145
              thread_id: thread.id!,
146
              body,
147
              address: address.address,
148
              timestamp: thread.created_at!,
149
              content_url: contentUrl,
150
            },
151
            {
152
              transaction,
153
            },
154
          );
155

156
          await models.ThreadSubscription.create(
10✔
157
            {
158
              user_id: actor.user.id!,
159
              thread_id: thread.id!,
160
            },
161
            { transaction },
162
          );
163

164
          mentions.length &&
10!
165
            (await emitMentions(transaction, {
166
              authorAddressId: address.id!,
167
              authorUserId: actor.user.id!,
168
              authorAddress: address.address,
169
              mentions: mentions,
170
              thread,
171
              community_id: thread.community_id,
172
            }));
173

174
          return thread.id;
10✔
175
        },
176
      );
177
      // == end of transaction boundary ==
178

179
      const thread = await models.Thread.findOne({
10✔
180
        where: { id: new_thread_id },
181
        include: [{ model: models.Address, as: 'Address' }],
182
      });
183
      return {
10✔
184
        ...thread!.toJSON(),
185
        topic: topic!.toJSON(),
186
      };
187
    },
188
  };
189
}
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

© 2025 Coveralls, Inc