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

hicommonwealth / commonwealth / 14475476648

15 Apr 2025 05:18PM UTC coverage: 46.141% (-0.1%) from 46.287%
14475476648

Pull #11664

github

web-flow
Merge faa64a68e into 84ecac07e
Pull Request #11664: Added `XpChainEventCreated` support

1622 of 3876 branches covered (41.85%)

Branch coverage included in aggregate %.

0 of 25 new or added lines in 2 files covered. (0.0%)

58 existing lines in 14 files now uncovered.

2976 of 6089 relevant lines covered (48.88%)

39.44 hits per line

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

59.38
/libs/model/src/aggregates/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 { models } from '../../database';
13
import {
14
  authTopic,
15
  mustBeAuthorized,
16
  mustBeValidCommunity,
17
  mustExist,
18
  tiered,
19
  turnstile,
20
  verifyThreadSignature,
21
} from '../../middleware';
22
import { getThreadSearchVector } from '../../models/thread';
23
import { tokenBalanceCache } from '../../services';
24
import {
25
  decodeContent,
26
  emitMentions,
27
  parseUserMentions,
28
  uniqueMentions,
29
  uploadIfLarge,
30
} from '../../utils';
31
import { GetActiveContestManagers } from '../contest';
32

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

46
const getActiveContestManagersQuery = GetActiveContestManagers();
32✔
47

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

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

89
export function CreateThread(): Command<typeof schemas.CreateThread> {
90
  return {
22✔
91
    ...schemas.CreateThread,
92
    auth: [
93
      authTopic({ action: schemas.PermissionEnum.CREATE_THREAD }),
94
      verifyThreadSignature,
95
      tiered({ creates: true }),
96
      turnstile({ widgetName: 'create-thread' }),
97
    ],
98
    body: async ({ actor, payload, context }) => {
99
      const { address } = mustBeAuthorized(actor, context);
15✔
100

101
      const { community_id, topic_id, kind, url, is_linking_token, ...rest } =
102
        payload;
15✔
103

104
      if (kind === 'link' && !url?.trim())
15!
UNCOV
105
        throw new InvalidInput(CreateThreadErrors.LinkMissingTitleOrUrl);
×
106

107
      const community = await models.Community.findOne({
15✔
108
        where: { id: community_id },
109
        attributes: ['spam_tier_level', 'tier', 'active'],
110
      });
111
      mustExist('Community', community);
15✔
112
      mustBeValidCommunity(community);
15✔
113

114
      const user = await models.User.findOne({
15✔
115
        where: { id: actor.user.id },
116
        attributes: ['tier'],
117
      });
118
      mustExist('User', user);
15✔
119

120
      const marked_as_spam_at =
121
        address.role === 'member' && user.tier <= community.spam_tier_level
15✔
122
          ? new Date()
123
          : null;
124

125
      const topic = await models.Topic.findOne({ where: { id: topic_id } });
15✔
126
      if (topic?.archived_at)
15!
UNCOV
127
        throw new InvalidState(CreateThreadErrors.ArchivedTopic);
×
128

129
      // check contest invariants
130
      const activeContestManagers = await getActiveContestManagersQuery.body({
15✔
131
        actor: {} as Actor,
132
        payload: {
133
          community_id,
134
          topic_id,
135
        },
136
      });
137
      if (activeContestManagers && activeContestManagers.length > 0) {
15!
UNCOV
138
        await checkAddressBalance(activeContestManagers, actor.address!);
×
UNCOV
139
        checkContestLimits(activeContestManagers, actor.address!);
×
140
      }
141

142
      const body = decodeContent(payload.body);
15✔
143
      const mentions = uniqueMentions(parseUserMentions(body));
15✔
144

145
      const { contentUrl } = await uploadIfLarge('threads', body);
15✔
146

147
      // == mutation transaction boundary ==
148
      const new_thread_id = await models.sequelize.transaction(
15✔
149
        async (transaction) => {
150
          const thread = await models.Thread.create(
15✔
151
            {
152
              ...rest,
153
              community_id,
154
              address_id: address.id!,
155
              topic_id,
156
              kind,
157
              body,
158
              view_count: 0,
159
              comment_count: 0,
160
              reaction_count: 0,
161
              reaction_weights_sum: '0',
162
              search: getThreadSearchVector(rest.title, body),
163
              content_url: contentUrl,
164
              is_linking_token,
165
              marked_as_spam_at,
166
            },
167
            {
168
              transaction,
169
            },
170
          );
171

172
          await models.ThreadVersionHistory.create(
15✔
173
            {
174
              thread_id: thread.id!,
175
              body,
176
              address: address.address,
177
              timestamp: thread.created_at!,
178
              content_url: contentUrl,
179
            },
180
            {
181
              transaction,
182
            },
183
          );
184

185
          await models.ThreadSubscription.create(
15✔
186
            {
187
              user_id: actor.user.id!,
188
              thread_id: thread.id!,
189
            },
190
            { transaction },
191
          );
192

193
          mentions.length &&
15!
194
            (await emitMentions(transaction, {
195
              authorAddressId: address.id!,
196
              authorUserId: actor.user.id!,
197
              authorAddress: address.address,
198
              mentions: mentions,
199
              thread,
200
              community_id: thread.community_id,
201
            }));
202

203
          return thread.id;
15✔
204
        },
205
      );
206
      // == end of transaction boundary ==
207

208
      const thread = await models.Thread.findOne({
15✔
209
        where: { id: new_thread_id },
210
        include: [{ model: models.Address, as: 'Address' }],
211
      });
212
      return {
15✔
213
        ...thread!.toJSON(),
214
        topic: topic!.toJSON(),
215
      };
216
    },
217
  };
218
}
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