Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apply contest rate limits #8475

Merged
merged 15 commits into from
Jul 18, 2024
2 changes: 1 addition & 1 deletion libs/core/src/framework/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const query = async <Input extends ZodSchema, Output extends ZodSchema>(
{ input, auth, body }: QueryMetadata<Input, Output>,
{ actor, payload }: QueryContext<Input>,
validate = true,
): Promise<Partial<z.infer<Output>> | undefined> => {
): Promise<z.infer<Output> | undefined> => {
try {
const context: QueryContext<Input> = {
actor,
Expand Down
2 changes: 1 addition & 1 deletion libs/core/src/framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export type CommandHandler<
*/
export type QueryHandler<Input extends ZodSchema, Output extends ZodSchema> = (
context: QueryContext<Input>,
) => Promise<Partial<z.infer<Output>> | undefined>;
) => Promise<z.infer<Output> | undefined>;

/**
* Event handler
Expand Down
11 changes: 11 additions & 0 deletions libs/model/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
TBC_BALANCE_TTL_SECONDS,
ALLOWED_EVENTS,
INIT_TEST_DB,
MAX_USER_POSTS_PER_CONTEST,
JWT_SECRET,
} = process.env;

Expand Down Expand Up @@ -46,6 +47,12 @@ export const config = configure(
OUTBOX: {
ALLOWED_EVENTS: ALLOWED_EVENTS ? ALLOWED_EVENTS.split(',') : [],
},
CONTESTS: {
MIN_USER_ETH: 0.0005,
MAX_USER_POSTS_PER_CONTEST: MAX_USER_POSTS_PER_CONTEST
? parseInt(MAX_USER_POSTS_PER_CONTEST, 10)
: 2,
},
AUTH: {
JWT_SECRET: JWT_SECRET || DEFAULTS.JWT_SECRET,
SESSION_EXPIRY_MILLIS: 30 * 24 * 60 * 60 * 1000,
Expand All @@ -68,6 +75,10 @@ export const config = configure(
OUTBOX: z.object({
ALLOWED_EVENTS: z.array(z.string()),
}),
CONTESTS: z.object({
MIN_USER_ETH: z.number(),
MAX_USER_POSTS_PER_CONTEST: z.number().int(),
}),
AUTH: z
.object({
JWT_SECRET: z.string(),
Expand Down
70 changes: 70 additions & 0 deletions libs/model/src/contest/GetActiveContestManagers.query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Query } from '@hicommonwealth/core';
import * as schemas from '@hicommonwealth/schemas';
import { QueryTypes } from 'sequelize';
import z from 'zod';
import { models } from '../database';

// GetActiveContestManagers returns all contest managers which are active
// in the specified community and topic, along with the actions within
// each manager's most recent contest
export function GetActiveContestManagers(): Query<
typeof schemas.GetActiveContestManagers
> {
return {
...schemas.GetActiveContestManagers,
auth: [],
body: async ({ payload }) => {
const results = await models.sequelize.query<{
eth_chain_id: number;
url: string;
contest_address: string;
max_contest_id: number;
actions: Array<z.infer<typeof schemas.ContestAction>>;
}>(
`
SELECT
cn.eth_chain_id,
COALESCE(cn.private_url, cn.url) as url,
cm.contest_address,
co.max_contest_id,
COALESCE(JSON_AGG(ca) FILTER (WHERE ca IS NOT NULL), '[]'::json) as actions
FROM "Communities" c
JOIN "ChainNodes" cn ON c.chain_node_id = cn.id
JOIN "ContestManagers" cm ON cm.community_id = c.id
JOIN "ContestTopics" ct ON cm.contest_address = ct.contest_address
JOIN (
SELECT contest_address,
MAX(contest_id) AS max_contest_id,
MAX(start_time) as start_time,
MAX(end_time) as end_time
FROM "Contests"
GROUP BY contest_address
) co ON cm.contest_address = co.contest_address
LEFT JOIN "ContestActions" ca on (
ca.contest_address = cm.contest_address AND
ca.created_at > co.start_time AND
ca.created_at < co.end_time
)
WHERE ct.topic_id = :topic_id
AND cm.community_id = :community_id
AND cm.cancelled = false
AND (
cm.interval = 0 AND NOW() < co.end_time
OR
cm.interval > 0
)
GROUP BY cn.eth_chain_id, cn.private_url, cn.url, cm.contest_address, co.max_contest_id
`,
{
type: QueryTypes.SELECT,
replacements: {
topic_id: payload.topic_id!,
community_id: payload.community_id,
},
},
);

return results;
},
};
}
1 change: 1 addition & 0 deletions libs/model/src/contest/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './CancelContestManagerMetadata.command';
export * from './Contests.projection';
export * from './CreateContestManagerMetadata.command';
export * from './GetActiveContestManagers.query';
export * from './GetAllContests.query';
export * from './GetContestLog.query';
export * from './PerformContestRollovers.command';
Expand Down
65 changes: 29 additions & 36 deletions libs/model/src/policies/ContestWorker.policy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { events, logger, Policy } from '@hicommonwealth/core';
import { Actor, events, logger, Policy } from '@hicommonwealth/core';
import { QueryTypes } from 'sequelize';
import { fileURLToPath } from 'url';
import { config, Contest } from '..';
import { models } from '../database';
import { contestHelper } from '../services/commonProtocol';
import { buildThreadContentUrl } from '../utils';
Expand Down Expand Up @@ -32,50 +33,42 @@ export function ContestWorker(): Policy<typeof inputs> {
payload.id!,
);

const activeContestManagers = await models.sequelize.query<{
contest_address: string;
url: string;
private_url: string;
}>(
`
SELECT COALESCE(cn.private_url, cn.url) as url, cm.contest_address
FROM "Communities" c
JOIN "ChainNodes" cn ON c.chain_node_id = cn.id
JOIN "ContestManagers" cm ON cm.community_id = c.id
JOIN "ContestTopics" ct ON cm.contest_address = ct.contest_address
JOIN (
SELECT contest_address, MAX(contest_id) AS max_contest_id, MAX(end_time) as end_time
FROM "Contests"
GROUP BY contest_address
) co ON cm.contest_address = co.contest_address
WHERE ct.topic_id = :topic_id
AND cm.community_id = :community_id
AND cm.cancelled = false
AND (
cm.interval = 0 AND NOW() < co.end_time
OR
cm.interval > 0
)
`,
{
type: QueryTypes.SELECT,
replacements: {
topic_id: payload.topic_id!,
const activeContestManagers =
await Contest.GetActiveContestManagers().body({
actor: {} as Actor,
payload: {
community_id: payload.community_id,
topic_id: payload.topic_id,
},
},
);

});
if (!activeContestManagers?.length) {
log.warn('ThreadCreated: no matching contest managers found');
return;
}

const chainNodeUrl = activeContestManagers[0]!.url;

const addressesToProcess = activeContestManagers.map(
(c) => c.contest_address,
);
const addressesToProcess = activeContestManagers
.filter((c) => {
// only process contest managers for which
// the user has not exceeded the post limit
// on the latest contest
const userPostsInContest = c.actions.filter(
(action) =>
action.actor_address === userAddress &&
action.action === 'added',
);
const quotaReached =
userPostsInContest.length >=
config.CONTESTS.MAX_USER_POSTS_PER_CONTEST;
if (quotaReached) {
log.warn(
`ThreadCreated: user reached post limit for contest ${c.contest_address} (ID ${c.max_contest_id})`,
);
}
return !quotaReached;
})
.map((c) => c.contest_address);

log.debug(
`ThreadCreated: addresses to process: ${JSON.stringify(
Expand Down
18 changes: 17 additions & 1 deletion libs/schemas/src/queries/contests.schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { ContestManager } from '../entities';
import { Contest } from '../projections';
import { Contest, ContestAction } from '../projections';
import { PG_INT, zDate } from '../utils';

export const ContestResults = ContestManager.extend({
Expand All @@ -25,6 +25,22 @@ export const GetAllContests = {
output: z.array(ContestResults),
};

export const GetActiveContestManagers = {
input: z.object({
community_id: z.string(),
topic_id: z.number(),
}),
output: z.array(
z.object({
eth_chain_id: z.number().int(),
url: z.string(),
contest_address: z.string(),
max_contest_id: z.number(),
actions: z.array(ContestAction),
}),
),
};

export const ContestLogEntry = z.object({
event_name: z.string(),
event_payload: z.object({}),
Expand Down
2 changes: 1 addition & 1 deletion libs/schemas/src/queries/subscription.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {

export const GetSubscriptionPreferences = {
input: z.object({}),
output: SubscriptionPreference,
output: z.union([SubscriptionPreference, z.object({})]),
};

export const GetCommunityAlerts = {
Expand Down
14 changes: 14 additions & 0 deletions packages/commonwealth/client/scripts/models/Topic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import * as schemas from '@hicommonwealth/schemas';
import { z } from 'zod';

const ActiveContestManagers = z.object({
content: z.array(schemas.ContestAction),
contest_manager: schemas.ContestManager,
});

export type TopicAttributes = {
name: string;
id: number;
Expand All @@ -10,6 +18,7 @@ export type TopicAttributes = {
default_offchain_template?: string;
total_threads: number;
channel_id?: string;
active_contest_managers: Array<z.infer<typeof ActiveContestManagers>>;
};

class Topic {
Expand All @@ -24,6 +33,9 @@ class Topic {
public order?: number;
public readonly defaultOffchainTemplate?: string;
public totalThreads?: number;
public readonly activeContestManagers: Array<
z.infer<typeof ActiveContestManagers>
>;

constructor({
name,
Expand All @@ -37,6 +49,7 @@ class Topic {
default_offchain_template,
total_threads,
channel_id,
active_contest_managers,
}: TopicAttributes) {
this.name = name;
this.id = id;
Expand All @@ -50,6 +63,7 @@ class Topic {
this.defaultOffchainTemplate = default_offchain_template;
this.totalThreads = total_threads || 0;
this.channelId = channel_id;
this.activeContestManagers = active_contest_managers || [];
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ const TOPICS_STALE_TIME = 30 * 1_000; // 30 s
interface FetchTopicsProps {
communityId: string;
apiEnabled?: boolean;
includeContestData?: boolean;
}

const fetchTopics = async ({
communityId,
includeContestData = false,
}: FetchTopicsProps): Promise<Topic[]> => {
const response = await axios.get(
`${app.serverUrl()}${ApiEndpoints.BULK_TOPICS}`,
{
params: {
community_id: communityId || app.activeChainId(),
with_contest_managers: includeContestData,
},
},
);
Expand All @@ -29,10 +32,11 @@ const fetchTopics = async ({
const useFetchTopicsQuery = ({
communityId,
apiEnabled = true,
includeContestData,
}: FetchTopicsProps) => {
return useQuery({
queryKey: [ApiEndpoints.BULK_TOPICS, communityId],
queryFn: () => fetchTopics({ communityId }),
queryKey: [ApiEndpoints.BULK_TOPICS, communityId, includeContestData],
queryFn: () => fetchTopics({ communityId, includeContestData }),
staleTime: TOPICS_STALE_TIME,
enabled: apiEnabled,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const ContestThreadBanner = ({
return (
<CWBanner
className="ContestThreadBanner"
title="This topic has an ongoing contest. Submit thread to contest?"
title="This topic has an ongoing contest(s). Submit thread to contest?"
body="Once a post is submitted it cannot be edited.
The post with the most upvotes will win the contest prize."
type="info"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@import '../../../../../styles/shared';

.ContestTopicBanner {
.body {
ul {
list-style: initial;
margin-left: 32px;
line-height: 32px;
margin-bottom: 8px;

li {
.Text.disabled {
color: $neutral-400;
}
}
}
}
}
Loading
Loading