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

hicommonwealth / commonwealth / 14198518114

01 Apr 2025 02:37PM UTC coverage: 45.602% (+0.3%) from 45.271%
14198518114

Pull #11706

github

web-flow
Merge 30952227e into d7ab14fcb
Pull Request #11706: Cursor rules for FE

1507 of 3693 branches covered (40.81%)

Branch coverage included in aggregate %.

2858 of 5879 relevant lines covered (48.61%)

39.34 hits per line

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

56.67
/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 { authTopic, tiered } from '../../middleware';
14
import { verifyThreadSignature } from '../../middleware/canvas';
15
import { mustBeAuthorized, mustExist } from '../../middleware/guards';
16
import { getThreadSearchVector } from '../../models/thread';
17
import { tokenBalanceCache } from '../../services';
18
import {
19
  decodeContent,
20
  emitMentions,
21
  parseUserMentions,
22
  uniqueMentions,
23
  uploadIfLarge,
24
} from '../../utils';
25
import { GetActiveContestManagers } from '../contest';
26

27
export const CreateThreadErrors = {
32✔
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();
32✔
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 {
22✔
85
    ...schemas.CreateThread,
86
    auth: [
87
      authTopic({ action: schemas.PermissionEnum.CREATE_THREAD }),
88
      verifyThreadSignature,
89
      tiered({ creates: true }),
90
    ],
91
    body: async ({ actor, payload, context }) => {
92
      const { address } = mustBeAuthorized(actor, context);
15✔
93

94
      const { community_id, topic_id, kind, url, is_linking_token, ...rest } =
95
        payload;
15✔
96

97
      if (kind === 'link' && !url?.trim())
15!
98
        throw new InvalidInput(CreateThreadErrors.LinkMissingTitleOrUrl);
×
99

100
      const community = await models.Community.findOne({
15✔
101
        where: { id: community_id },
102
        attributes: ['spam_tier_level'],
103
      });
104
      mustExist('Community', community);
15✔
105

106
      const user = await models.User.findOne({
15✔
107
        where: { id: actor.user.id },
108
        attributes: ['tier'],
109
      });
110
      mustExist('User', user);
15✔
111

112
      const topic = await models.Topic.findOne({ where: { id: topic_id } });
15✔
113
      if (topic?.archived_at)
15!
114
        throw new InvalidState(CreateThreadErrors.ArchivedTopic);
×
115

116
      // check contest invariants
117
      const activeContestManagers = await getActiveContestManagersQuery.body({
15✔
118
        actor: {} as Actor,
119
        payload: {
120
          community_id,
121
          topic_id,
122
        },
123
      });
124
      if (activeContestManagers && activeContestManagers.length > 0) {
15!
125
        await checkAddressBalance(activeContestManagers, actor.address!);
×
126
        checkContestLimits(activeContestManagers, actor.address!);
×
127
      }
128

129
      const body = decodeContent(payload.body);
15✔
130
      const mentions = uniqueMentions(parseUserMentions(body));
15✔
131

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

134
      // == mutation transaction boundary ==
135
      const new_thread_id = await models.sequelize.transaction(
15✔
136
        async (transaction) => {
137
          const thread = await models.Thread.create(
15✔
138
            {
139
              ...rest,
140
              community_id,
141
              address_id: address.id!,
142
              topic_id,
143
              kind,
144
              body,
145
              view_count: 0,
146
              comment_count: 0,
147
              reaction_count: 0,
148
              reaction_weights_sum: '0',
149
              search: getThreadSearchVector(rest.title, body),
150
              content_url: contentUrl,
151
              is_linking_token,
152
              marked_as_spam_at:
153
                user.tier <= community.spam_tier_level ? new Date() : null,
15✔
154
            },
155
            {
156
              transaction,
157
            },
158
          );
159

160
          await models.ThreadVersionHistory.create(
15✔
161
            {
162
              thread_id: thread.id!,
163
              body,
164
              address: address.address,
165
              timestamp: thread.created_at!,
166
              content_url: contentUrl,
167
            },
168
            {
169
              transaction,
170
            },
171
          );
172

173
          await models.ThreadSubscription.create(
15✔
174
            {
175
              user_id: actor.user.id!,
176
              thread_id: thread.id!,
177
            },
178
            { transaction },
179
          );
180

181
          mentions.length &&
15!
182
            (await emitMentions(transaction, {
183
              authorAddressId: address.id!,
184
              authorUserId: actor.user.id!,
185
              authorAddress: address.address,
186
              mentions: mentions,
187
              thread,
188
              community_id: thread.community_id,
189
            }));
190

191
          return thread.id;
15✔
192
        },
193
      );
194
      // == end of transaction boundary ==
195

196
      const thread = await models.Thread.findOne({
15✔
197
        where: { id: new_thread_id },
198
        include: [{ model: models.Address, as: 'Address' }],
199
      });
200
      return {
15✔
201
        ...thread!.toJSON(),
202
        topic: topic!.toJSON(),
203
      };
204
    },
205
  };
206
}
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