Skip to content

Commit 1d4d4fa

Browse files
authored
feat(contributions): add assist types and action link pool (#3951)
1 parent 4594b61 commit 1d4d4fa

9 files changed

Lines changed: 402 additions & 0 deletions

File tree

__tests__/contributions.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { User } from '../src/entity/user/User';
1717
import * as njordCommon from '../src/common/njord';
1818
import { SubscriptionCycles } from '../src/paddle';
1919
import { ContributionAction } from '../src/entity/contribution/ContributionAction';
20+
import { ContributionActionLink } from '../src/entity/contribution/ContributionActionLink';
2021
import { ContributionActionCategory } from '../src/entity/contribution/ContributionActionCategory';
2122
import { ContributionBlockedUser } from '../src/entity/contribution/ContributionBlockedUser';
2223
import { ContributionCause } from '../src/entity/contribution/ContributionCause';
@@ -106,6 +107,16 @@ query ContributionActions($categoryId: ID) {
106107
}
107108
`;
108109

110+
const CONTRIBUTION_ACTION_LINKS_QUERY = `
111+
query ContributionActionLinks($actionId: ID!, $limit: Int) {
112+
contributionActionLinks(actionId: $actionId, limit: $limit) {
113+
id
114+
url
115+
label
116+
}
117+
}
118+
`;
119+
109120
const USER_CONTRIBUTION_SUBMISSIONS_QUERY = `
110121
query UserContributionSubmissions($actionId: ID) {
111122
userContributionSubmissions(actionId: $actionId, first: 10) {
@@ -582,6 +593,36 @@ it('returns actions by category and records approved submissions with limits', a
582593
);
583594
});
584595

596+
it('returns a randomized handful of active pool links for an action', async () => {
597+
await seedActions();
598+
await saveFixtures(con, ContributionActionLink, [
599+
{ actionId, url: 'https://stackoverflow.com/q/1' },
600+
{ actionId, url: 'https://stackoverflow.com/q/2' },
601+
{ actionId, url: 'https://stackoverflow.com/q/3' },
602+
{ actionId, url: 'https://stackoverflow.com/q/4', active: false },
603+
]);
604+
605+
const limited = await client.query(CONTRIBUTION_ACTION_LINKS_QUERY, {
606+
variables: { actionId, limit: 2 },
607+
});
608+
609+
expect(limited.errors).toBeUndefined();
610+
expect(limited.data.contributionActionLinks).toHaveLength(2);
611+
612+
const all = await client.query(CONTRIBUTION_ACTION_LINKS_QUERY, {
613+
variables: { actionId },
614+
});
615+
616+
const urls = all.data.contributionActionLinks
617+
.map((link: { url: string }) => link.url)
618+
.sort();
619+
expect(urls).toEqual([
620+
'https://stackoverflow.com/q/1',
621+
'https://stackoverflow.com/q/2',
622+
'https://stackoverflow.com/q/3',
623+
]);
624+
});
625+
585626
it('updates cause preferences and claims unlocked reward tiers', async () => {
586627
await seedCauses();
587628
await saveFixtures(con, ContributionRewardTier, [

__tests__/routes/private/contributions.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { saveFixtures } from '../../helpers';
77
import { User } from '../../../src/entity/user/User';
88
import { ContributionAction } from '../../../src/entity/contribution/ContributionAction';
99
import { ContributionActionCategory } from '../../../src/entity/contribution/ContributionActionCategory';
10+
import { ContributionActionLink } from '../../../src/entity/contribution/ContributionActionLink';
1011
import { ContributionBlockedUser } from '../../../src/entity/contribution/ContributionBlockedUser';
1112
import { ContributionCause } from '../../../src/entity/contribution/ContributionCause';
1213
import {
@@ -280,6 +281,63 @@ describe('private contribution routes', () => {
280281
).resolves.toBeNull();
281282
});
282283

284+
it('manages action links individually and in bulk', async () => {
285+
await seedContributionConfig();
286+
287+
const { body: link } = await request(app.server)
288+
.post(`/p/contributions/actions/${actionId}/links`)
289+
.set(serviceHeaders)
290+
.send({ url: 'https://stackoverflow.com/q/1', label: 'Question 1' })
291+
.expect(201);
292+
293+
const { body: bulk } = await request(app.server)
294+
.post(`/p/contributions/actions/${actionId}/links/bulk`)
295+
.set(serviceHeaders)
296+
.send({
297+
links: [
298+
{ url: 'https://stackoverflow.com/q/2' },
299+
{ url: 'https://stackoverflow.com/q/3', sortOrder: 5 },
300+
],
301+
})
302+
.expect(201);
303+
304+
expect(bulk.count).toBe(2);
305+
306+
await request(app.server)
307+
.patch(`/p/contributions/links/${link.id}`)
308+
.set(serviceHeaders)
309+
.send({ label: 'Updated', active: false })
310+
.expect(200);
311+
312+
await request(app.server)
313+
.delete(`/p/contributions/links/${bulk.links[0].id}`)
314+
.set(serviceAuthHeaders)
315+
.expect(200);
316+
317+
const links = await con
318+
.getRepository(ContributionActionLink)
319+
.find({ where: { actionId }, order: { url: 'ASC' } });
320+
321+
expect(links).toHaveLength(2);
322+
expect(links).toMatchObject([
323+
{ url: 'https://stackoverflow.com/q/1', label: 'Updated', active: false },
324+
{ url: 'https://stackoverflow.com/q/3', sortOrder: 5 },
325+
]);
326+
});
327+
328+
it('returns 404 when updating or deleting a missing link', async () => {
329+
await request(app.server)
330+
.patch(`/p/contributions/links/${actionId}`)
331+
.set(serviceHeaders)
332+
.send({ label: 'Updated' })
333+
.expect(404);
334+
335+
await request(app.server)
336+
.delete(`/p/contributions/links/${actionId}`)
337+
.set(serviceAuthHeaders)
338+
.expect(404);
339+
});
340+
283341
it('reviews submissions, fulfills rewards, and blocks users', async () => {
284342
await seedContributionConfig();
285343
await saveFixtures(con, ContributionPayment, [

src/common/schema/contributions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import z from 'zod';
2+
import { ContributionAssistType } from '../../entity/contribution/ContributionAction';
23
import { ContributionRewardType } from '../../entity/contribution/ContributionRewardTier';
34
import { ContributionSubmissionStatus } from '../../entity/contribution/ContributionSubmission';
45
import { enumValues } from './utils';
@@ -30,6 +31,7 @@ export const contributionActionMetadataSchema = z
3031
instructions: z.string().trim().min(1).optional(),
3132
externalUrl: z.url().optional(),
3233
isLoveAction: z.boolean().optional(),
34+
assistType: z.enum(enumValues(ContributionAssistType)).optional(),
3335
})
3436
.strict();
3537

@@ -56,6 +58,11 @@ export const contributionSubmissionsArgsSchema =
5658
actionId: z.uuid().nullish(),
5759
});
5860

61+
export const contributionActionLinksArgsSchema = z.object({
62+
actionId: z.uuid(),
63+
limit: z.number().int().positive().max(20).nullish(),
64+
});
65+
5966
export const submitContributionActionInputSchema = z.object({
6067
actionId: z.uuid(),
6168
evidence: contributionSubmissionEvidenceSchema,
@@ -223,3 +230,21 @@ export const contributionPrivateFinalizePaymentSchema = z.object({
223230
amountCents: z.number().int().positive(),
224231
createdBy: contributionUserIdSchema.nullish(),
225232
});
233+
234+
export const contributionPrivateCreateActionLinkSchema = z.object({
235+
url: z.url(),
236+
label: z.string().trim().min(1).nullish(),
237+
active: contributionActiveSchema,
238+
sortOrder: contributionSortOrderSchema,
239+
});
240+
241+
export const contributionPrivateBulkCreateActionLinkSchema = z.object({
242+
links: z.array(contributionPrivateCreateActionLinkSchema).min(1).max(500),
243+
});
244+
245+
export const contributionPrivateUpdateActionLinkSchema = z.object({
246+
url: z.url().optional(),
247+
label: z.string().trim().min(1).nullish(),
248+
active: contributionActiveSchema,
249+
sortOrder: contributionSortOrderSchema,
250+
});

src/entity/contribution/ContributionAction.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,20 @@ export type ContributionEvidenceSchema = {
2222
};
2323
};
2424

25+
export enum ContributionAssistType {
26+
ExternalLink = 'external_link',
27+
ReferralLink = 'referral_link',
28+
LinkPool = 'link_pool',
29+
}
30+
2531
export type ContributionActionMetadata = {
2632
platform?: string;
2733
instructions?: string;
2834
externalUrl?: string;
2935
isLoveAction?: boolean;
36+
// How the UI helps the user complete the action: open a link, surface their
37+
// referral link, or pick from a rotating pool (contribution_action_link).
38+
assistType?: ContributionAssistType;
3039
};
3140

3241
@Entity()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
Column,
3+
Entity,
4+
Index,
5+
JoinColumn,
6+
ManyToOne,
7+
PrimaryGeneratedColumn,
8+
UpdateDateColumn,
9+
} from 'typeorm';
10+
import type { ContributionAction } from './ContributionAction';
11+
12+
// A pool of target links for a single action (e.g. hundreds of community
13+
// questions to answer). The UI surfaces a randomized handful at a time.
14+
@Entity()
15+
@Index('IDX_contribution_action_link_action_active', [
16+
'actionId',
17+
'active',
18+
'sortOrder',
19+
])
20+
export class ContributionActionLink {
21+
@PrimaryGeneratedColumn('uuid', {
22+
primaryKeyConstraintName: 'PK_contribution_action_link_id',
23+
})
24+
id: string;
25+
26+
@Column({ default: () => 'now()' })
27+
createdAt: Date;
28+
29+
@UpdateDateColumn()
30+
updatedAt: Date;
31+
32+
@Column({ type: 'uuid' })
33+
actionId: string;
34+
35+
@Column({ type: 'text' })
36+
url: string;
37+
38+
@Column({ type: 'text', nullable: true, default: null })
39+
label: string | null;
40+
41+
@Column({ type: 'boolean', default: true })
42+
active: boolean;
43+
44+
@Column({ type: 'integer', default: 0 })
45+
sortOrder: number;
46+
47+
@ManyToOne('ContributionAction', {
48+
lazy: true,
49+
onDelete: 'CASCADE',
50+
})
51+
@JoinColumn({
52+
name: 'actionId',
53+
foreignKeyConstraintName: 'FK_contribution_action_link_action_id',
54+
})
55+
action: Promise<ContributionAction>;
56+
}

src/entity/contribution/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './ContributionAction';
22
export * from './ContributionActionCategory';
3+
export * from './ContributionActionLink';
34
export * from './ContributionBlockedUser';
45
export * from './ContributionCause';
56
export * from './ContributionPayment';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddContributionActionLink1782100000000
4+
implements MigrationInterface
5+
{
6+
name = 'AddContributionActionLink1782100000000';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(/* sql */ `
10+
CREATE TABLE "contribution_action_link" (
11+
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
12+
"createdAt" timestamp NOT NULL DEFAULT now(),
13+
"updatedAt" timestamp NOT NULL DEFAULT now(),
14+
"actionId" uuid NOT NULL,
15+
"url" text NOT NULL,
16+
"label" text,
17+
"active" boolean NOT NULL DEFAULT true,
18+
"sortOrder" integer NOT NULL DEFAULT '0',
19+
CONSTRAINT "PK_contribution_action_link_id"
20+
PRIMARY KEY ("id")
21+
)
22+
`);
23+
24+
await queryRunner.query(/* sql */ `
25+
CREATE INDEX IF NOT EXISTS "IDX_contribution_action_link_action_active"
26+
ON "contribution_action_link" ("actionId", "active", "sortOrder")
27+
`);
28+
29+
await queryRunner.query(/* sql */ `
30+
ALTER TABLE "contribution_action_link"
31+
ADD CONSTRAINT "FK_contribution_action_link_action_id"
32+
FOREIGN KEY ("actionId")
33+
REFERENCES "contribution_action"("id")
34+
ON DELETE CASCADE
35+
ON UPDATE NO ACTION
36+
`);
37+
}
38+
39+
public async down(queryRunner: QueryRunner): Promise<void> {
40+
await queryRunner.query(/* sql */ `
41+
ALTER TABLE "contribution_action_link"
42+
DROP CONSTRAINT "FK_contribution_action_link_action_id"
43+
`);
44+
45+
await queryRunner.query(/* sql */ `
46+
DROP INDEX IF EXISTS "IDX_contribution_action_link_action_active"
47+
`);
48+
49+
await queryRunner.query(/* sql */ `
50+
DROP TABLE "contribution_action_link"
51+
`);
52+
}
53+
}

0 commit comments

Comments
 (0)