Skip to content
Merged
639 changes: 639 additions & 0 deletions __tests__/campaigns.ts

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions src/entity/campaign/Campaign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
TableInheritance,
UpdateDateColumn,
} from 'typeorm';
import type { User } from '../user';

export enum CampaignType {
Post = 'post',
Source = 'source',
}

export enum CampaignState {
Pending = 'pending',
Active = 'active',
Completed = 'completed',
Cancelled = 'cancelled',
}

export interface CampaignFlags {
budget: number;
spend: number;
impressions: number;
clicks: number;
users: number;
}

@Entity()
@Index('IDX_campaign_state_created_at_sort', { synchronize: false })
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
export class Campaign {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'text' })
referenceId: string;

@Column({ type: 'text' })
userId: string;

@ManyToOne('User', {
lazy: true,
onDelete: 'CASCADE',
})
user: Promise<User>;

@Column({ type: 'text' })
@Index('IDX_campaign_type')
type: CampaignType;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;

@Column()
endedAt: Date;

@Column({ type: 'text' })
state: CampaignState;

@Column({ type: 'jsonb', default: {} })
flags: Partial<CampaignFlags>;
}
12 changes: 12 additions & 0 deletions src/entity/campaign/CampaignPost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ChildEntity, Column, ManyToOne } from 'typeorm';
import type { Post } from '../posts';
import { Campaign, CampaignType } from './Campaign';

@ChildEntity(CampaignType.Post)
export class CampaignPost extends Campaign {
@Column({ type: 'text', default: null })
postId: string;

@ManyToOne('Post', { lazy: true, onDelete: 'CASCADE' })
post: Promise<Post>;
}
12 changes: 12 additions & 0 deletions src/entity/campaign/CampaignSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ChildEntity, Column, ManyToOne } from 'typeorm';
import type { Source } from '../';
import { Campaign, CampaignType } from './Campaign';

@ChildEntity(CampaignType.Source)
export class CampaignSource extends Campaign {
@Column({ type: 'text', default: null })
sourceId: string;

@ManyToOne('Source', { lazy: true, onDelete: 'CASCADE' })
source: Promise<Source>;
}
3 changes: 3 additions & 0 deletions src/entity/campaign/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './Campaign';
export * from './CampaignPost';
export * from './CampaignSource';
8 changes: 8 additions & 0 deletions src/graphorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,14 @@ const obj = new GraphORM({
},
},
},
Campaign: {
requiredColumns: ['id', 'createdAt'],
fields: {
flags: {
jsonType: true,
},
},
},
});

export default obj;
3 changes: 3 additions & 0 deletions src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as paddle from './schema/paddle';
import * as njord from './schema/njord';
import * as organizations from './schema/organizations';
import * as userExperience from './schema/userExperience';
import * as campaigns from './schema/campaigns';
import { makeExecutableSchema } from '@graphql-tools/schema';
import {
rateLimitTypeDefs,
Expand Down Expand Up @@ -76,6 +77,7 @@ export const schema = urlDirective.transformer(
njord.typeDefs,
organizations.typeDefs,
userExperience.typeDefs,
campaigns.typeDefs,
],
resolvers: merge(
common.resolvers,
Expand Down Expand Up @@ -105,6 +107,7 @@ export const schema = urlDirective.transformer(
njord.resolvers,
organizations.resolvers,
userExperience.resolvers,
campaigns.resolvers,
),
}),
),
Expand Down
45 changes: 45 additions & 0 deletions src/migration/1754650534998-CampaignEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CampaignEntity1754650534998 implements MigrationInterface {
name = 'CampaignEntity1754650534998';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "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"))`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC this can also have IF NOT EXISTS too. And IF EXISTS on drop table.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. It worked.

);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_campaign_type" ON "campaign" ("type") `,
);
await queryRunner.query(
`ALTER TABLE "campaign" ADD CONSTRAINT "FK_8e2dc400e55e237feba0869bc02" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "campaign" ADD CONSTRAINT "FK_9074c52d57e727dda6591943b10" FOREIGN KEY ("postId") REFERENCES "post"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "campaign" ADD CONSTRAINT "FK_a8102fd41bef084f19474e97953" FOREIGN KEY ("sourceId") REFERENCES "source"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_campaign_state_created_at_sort" ON "campaign" ((CASE WHEN state = 'active' THEN 0 ELSE 1 END), "createdAt" DESC) `,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "public"."IDX_campaign_state_created_at_sort"`,
);
await queryRunner.query(
`ALTER TABLE "campaign" DROP CONSTRAINT "FK_a8102fd41bef084f19474e97953"`,
);
await queryRunner.query(
`ALTER TABLE "campaign" DROP CONSTRAINT "FK_9074c52d57e727dda6591943b10"`,
);
await queryRunner.query(
`ALTER TABLE "campaign" DROP CONSTRAINT "FK_8e2dc400e55e237feba0869bc02"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "public"."IDX_campaign_type"`,
);
await queryRunner.query(`DROP TABLE "campaign"`);
}
}
134 changes: 134 additions & 0 deletions src/schema/campaigns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {
cursorToOffset,
offsetToCursor,
type Connection,
type ConnectionArguments,
} from 'graphql-relay';
import { IResolvers } from '@graphql-tools/utils';
import { AuthContext, BaseContext, Context } from '../Context';
import { traceResolvers } from './trace';

import graphorm from '../graphorm';
import { CampaignState, type Campaign } from '../entity/campaign';
import type { GQLPost } from './posts';
import type { GQLSource } from './sources';
import { getLimit } from '../common';

interface GQLCampaign
extends Pick<
Campaign,
'id' | 'type' | 'flags' | 'createdAt' | 'endedAt' | 'referenceId' | 'state'
> {
post: GQLPost;
source: GQLSource;
}

export const typeDefs = /* GraphQL */ `
type CampaignFlags {
budget: Int!
spend: Int!
users: Int!
clicks: Int!
impressions: Int!
}

type Campaign {
id: String!
type: String!
state: String!
createdAt: DateTime!
endedAt: DateTime!
flags: CampaignFlags!
post: Post
source: Source
}

type CampaignEdge {
node: Campaign!
"""
Used in before and after args
"""
cursor: String!
}

type CampaignConnection {
pageInfo: PageInfo!
edges: [CampaignEdge]!
}

extend type Query {
campaignById(
"""
ID of the campaign to fetch
"""
id: ID!
): Campaign! @auth

campaignsList(
"""
Paginate after opaque cursor
"""
after: String
"""
Paginate first
"""
first: Int
): CampaignConnection! @auth
}
`;

export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
unknown,
BaseContext
>({
Query: {
campaignById: async (
_,
{ id }: { id: string },
ctx: Context,
info,
): Promise<GQLCampaign> =>
graphorm.queryOneOrFail(ctx, info, (builder) => {
builder.queryBuilder.where({ id }).andWhere({ userId: ctx.userId });

return builder;
}),
campaignsList: async (
_,
args: ConnectionArguments,
ctx: AuthContext,
info,
): Promise<Connection<GQLCampaign>> => {
const { userId } = ctx;
const { after, first = 20 } = args;
const offset = after ? cursorToOffset(after) : 0;

return graphorm.queryPaginated(
ctx,
info,
() => !!after,
(nodeSize) => nodeSize === first,
(_, i) => offsetToCursor(offset + i + 1),
(builder) => {
const { alias } = builder;

builder.queryBuilder.andWhere(`"${alias}"."userId" = :userId`, {
userId,
});

builder.queryBuilder.orderBy(
`CASE WHEN "${alias}"."state" = '${CampaignState.Active}' THEN 0 ELSE 1 END`,
);
builder.queryBuilder.addOrderBy(`"${alias}"."createdAt"`, 'DESC');
builder.queryBuilder.limit(getLimit({ limit: first ?? 20 }));

if (after) {
builder.queryBuilder.offset(offset);
}

return builder;
},
);
},
},
});
Loading