Skip to content

Commit 491fcee

Browse files
sshanzelcapJavert
andauthored
feat: campaign entity and its queries (#2969)
Co-authored-by: Ante Barić <[email protected]>
1 parent 7152bae commit 491fcee

File tree

9 files changed

+926
-0
lines changed

9 files changed

+926
-0
lines changed

__tests__/campaigns.ts

Lines changed: 639 additions & 0 deletions
Large diffs are not rendered by default.

src/entity/campaign/Campaign.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
Column,
3+
CreateDateColumn,
4+
Entity,
5+
Index,
6+
ManyToOne,
7+
PrimaryGeneratedColumn,
8+
TableInheritance,
9+
UpdateDateColumn,
10+
} from 'typeorm';
11+
import type { User } from '../user';
12+
13+
export enum CampaignType {
14+
Post = 'post',
15+
Source = 'source',
16+
}
17+
18+
export enum CampaignState {
19+
Pending = 'pending',
20+
Active = 'active',
21+
Completed = 'completed',
22+
Cancelled = 'cancelled',
23+
}
24+
25+
export interface CampaignFlags {
26+
budget: number;
27+
spend: number;
28+
impressions: number;
29+
clicks: number;
30+
users: number;
31+
}
32+
33+
@Entity()
34+
@Index('IDX_campaign_state_created_at_sort', { synchronize: false })
35+
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
36+
export class Campaign {
37+
@PrimaryGeneratedColumn('uuid')
38+
id: string;
39+
40+
@Column({ type: 'text' })
41+
referenceId: string;
42+
43+
@Column({ type: 'text' })
44+
userId: string;
45+
46+
@ManyToOne('User', {
47+
lazy: true,
48+
onDelete: 'CASCADE',
49+
})
50+
user: Promise<User>;
51+
52+
@Column({ type: 'text' })
53+
@Index('IDX_campaign_type')
54+
type: CampaignType;
55+
56+
@CreateDateColumn()
57+
createdAt: Date;
58+
59+
@UpdateDateColumn()
60+
updatedAt: Date;
61+
62+
@Column()
63+
endedAt: Date;
64+
65+
@Column({ type: 'text' })
66+
state: CampaignState;
67+
68+
@Column({ type: 'jsonb', default: {} })
69+
flags: Partial<CampaignFlags>;
70+
}

src/entity/campaign/CampaignPost.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ChildEntity, Column, ManyToOne } from 'typeorm';
2+
import type { Post } from '../posts';
3+
import { Campaign, CampaignType } from './Campaign';
4+
5+
@ChildEntity(CampaignType.Post)
6+
export class CampaignPost extends Campaign {
7+
@Column({ type: 'text', default: null })
8+
postId: string;
9+
10+
@ManyToOne('Post', { lazy: true, onDelete: 'CASCADE' })
11+
post: Promise<Post>;
12+
}

src/entity/campaign/CampaignSource.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ChildEntity, Column, ManyToOne } from 'typeorm';
2+
import type { Source } from '../';
3+
import { Campaign, CampaignType } from './Campaign';
4+
5+
@ChildEntity(CampaignType.Source)
6+
export class CampaignSource extends Campaign {
7+
@Column({ type: 'text', default: null })
8+
sourceId: string;
9+
10+
@ManyToOne('Source', { lazy: true, onDelete: 'CASCADE' })
11+
source: Promise<Source>;
12+
}

src/entity/campaign/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './Campaign';
2+
export * from './CampaignPost';
3+
export * from './CampaignSource';

src/graphorm/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,14 @@ const obj = new GraphORM({
13601360
},
13611361
},
13621362
},
1363+
Campaign: {
1364+
requiredColumns: ['id', 'createdAt'],
1365+
fields: {
1366+
flags: {
1367+
jsonType: true,
1368+
},
1369+
},
1370+
},
13631371
});
13641372

13651373
export default obj;

src/graphql.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import * as paddle from './schema/paddle';
3030
import * as njord from './schema/njord';
3131
import * as organizations from './schema/organizations';
3232
import * as userExperience from './schema/userExperience';
33+
import * as campaigns from './schema/campaigns';
3334
import { makeExecutableSchema } from '@graphql-tools/schema';
3435
import {
3536
rateLimitTypeDefs,
@@ -76,6 +77,7 @@ export const schema = urlDirective.transformer(
7677
njord.typeDefs,
7778
organizations.typeDefs,
7879
userExperience.typeDefs,
80+
campaigns.typeDefs,
7981
],
8082
resolvers: merge(
8183
common.resolvers,
@@ -105,6 +107,7 @@ export const schema = urlDirective.transformer(
105107
njord.resolvers,
106108
organizations.resolvers,
107109
userExperience.resolvers,
110+
campaigns.resolvers,
108111
),
109112
}),
110113
),
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class CampaignEntity1754650534998 implements MigrationInterface {
4+
name = 'CampaignEntity1754650534998';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`CREATE TABLE IF NOT EXISTS "campaign" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "referenceId" text NOT NULL, "userId" character varying NOT NULL, "type" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "endedAt" TIMESTAMP NOT NULL, "state" text NOT NULL, "flags" jsonb NOT NULL DEFAULT '{}', "postId" text, "sourceId" text, CONSTRAINT "PK_0ce34d26e7f2eb316a3a592cdc4" PRIMARY KEY ("id"))`,
9+
);
10+
await queryRunner.query(
11+
`CREATE INDEX IF NOT EXISTS "IDX_campaign_type" ON "campaign" ("type") `,
12+
);
13+
await queryRunner.query(
14+
`ALTER TABLE "campaign" ADD CONSTRAINT "FK_8e2dc400e55e237feba0869bc02" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
15+
);
16+
await queryRunner.query(
17+
`ALTER TABLE "campaign" ADD CONSTRAINT "FK_9074c52d57e727dda6591943b10" FOREIGN KEY ("postId") REFERENCES "post"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
18+
);
19+
await queryRunner.query(
20+
`ALTER TABLE "campaign" ADD CONSTRAINT "FK_a8102fd41bef084f19474e97953" FOREIGN KEY ("sourceId") REFERENCES "source"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
21+
);
22+
await queryRunner.query(
23+
`CREATE INDEX IF NOT EXISTS "IDX_campaign_state_created_at_sort" ON "campaign" ((CASE WHEN state = 'active' THEN 0 ELSE 1 END), "createdAt" DESC) `,
24+
);
25+
}
26+
27+
public async down(queryRunner: QueryRunner): Promise<void> {
28+
await queryRunner.query(
29+
`DROP INDEX IF EXISTS "public"."IDX_campaign_state_created_at_sort"`,
30+
);
31+
await queryRunner.query(
32+
`ALTER TABLE "campaign" DROP CONSTRAINT "FK_a8102fd41bef084f19474e97953"`,
33+
);
34+
await queryRunner.query(
35+
`ALTER TABLE "campaign" DROP CONSTRAINT "FK_9074c52d57e727dda6591943b10"`,
36+
);
37+
await queryRunner.query(
38+
`ALTER TABLE "campaign" DROP CONSTRAINT "FK_8e2dc400e55e237feba0869bc02"`,
39+
);
40+
await queryRunner.query(
41+
`DROP INDEX IF EXISTS "public"."IDX_campaign_type"`,
42+
);
43+
await queryRunner.query(`DROP TABLE IF EXISTS "campaign"`);
44+
}
45+
}

src/schema/campaigns.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import {
2+
cursorToOffset,
3+
offsetToCursor,
4+
type Connection,
5+
type ConnectionArguments,
6+
} from 'graphql-relay';
7+
import { IResolvers } from '@graphql-tools/utils';
8+
import { AuthContext, BaseContext, Context } from '../Context';
9+
import { traceResolvers } from './trace';
10+
11+
import graphorm from '../graphorm';
12+
import { CampaignState, type Campaign } from '../entity/campaign';
13+
import type { GQLPost } from './posts';
14+
import type { GQLSource } from './sources';
15+
import { getLimit } from '../common';
16+
17+
interface GQLCampaign
18+
extends Pick<
19+
Campaign,
20+
'id' | 'type' | 'flags' | 'createdAt' | 'endedAt' | 'referenceId' | 'state'
21+
> {
22+
post: GQLPost;
23+
source: GQLSource;
24+
}
25+
26+
export const typeDefs = /* GraphQL */ `
27+
type CampaignFlags {
28+
budget: Int!
29+
spend: Int!
30+
users: Int!
31+
clicks: Int!
32+
impressions: Int!
33+
}
34+
35+
type Campaign {
36+
id: String!
37+
type: String!
38+
state: String!
39+
createdAt: DateTime!
40+
endedAt: DateTime!
41+
flags: CampaignFlags!
42+
post: Post
43+
source: Source
44+
}
45+
46+
type CampaignEdge {
47+
node: Campaign!
48+
"""
49+
Used in before and after args
50+
"""
51+
cursor: String!
52+
}
53+
54+
type CampaignConnection {
55+
pageInfo: PageInfo!
56+
edges: [CampaignEdge]!
57+
}
58+
59+
extend type Query {
60+
campaignById(
61+
"""
62+
ID of the campaign to fetch
63+
"""
64+
id: ID!
65+
): Campaign! @auth
66+
67+
campaignsList(
68+
"""
69+
Paginate after opaque cursor
70+
"""
71+
after: String
72+
"""
73+
Paginate first
74+
"""
75+
first: Int
76+
): CampaignConnection! @auth
77+
}
78+
`;
79+
80+
export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
81+
unknown,
82+
BaseContext
83+
>({
84+
Query: {
85+
campaignById: async (
86+
_,
87+
{ id }: { id: string },
88+
ctx: Context,
89+
info,
90+
): Promise<GQLCampaign> =>
91+
graphorm.queryOneOrFail(ctx, info, (builder) => {
92+
builder.queryBuilder.where({ id }).andWhere({ userId: ctx.userId });
93+
94+
return builder;
95+
}),
96+
campaignsList: async (
97+
_,
98+
args: ConnectionArguments,
99+
ctx: AuthContext,
100+
info,
101+
): Promise<Connection<GQLCampaign>> => {
102+
const { userId } = ctx;
103+
const { after, first = 20 } = args;
104+
const offset = after ? cursorToOffset(after) : 0;
105+
106+
return graphorm.queryPaginated(
107+
ctx,
108+
info,
109+
() => !!after,
110+
(nodeSize) => nodeSize === first,
111+
(_, i) => offsetToCursor(offset + i + 1),
112+
(builder) => {
113+
const { alias } = builder;
114+
115+
builder.queryBuilder.andWhere(`"${alias}"."userId" = :userId`, {
116+
userId,
117+
});
118+
119+
builder.queryBuilder.orderBy(
120+
`CASE WHEN "${alias}"."state" = '${CampaignState.Active}' THEN 0 ELSE 1 END`,
121+
);
122+
builder.queryBuilder.addOrderBy(`"${alias}"."createdAt"`, 'DESC');
123+
builder.queryBuilder.limit(getLimit({ limit: first ?? 20 }));
124+
125+
if (after) {
126+
builder.queryBuilder.offset(offset);
127+
}
128+
129+
return builder;
130+
},
131+
);
132+
},
133+
},
134+
});

0 commit comments

Comments
 (0)