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

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions src/entity/campaign/Campaign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryColumn,
TableInheritance,
} from 'typeorm';
import { 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()
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
export class Campaign {
@PrimaryColumn({ type: 'uuid', generated: '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;

@Column({ default: () => 'now()' })
createdAt: Date;

@Column()
endedAt: Date;

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

@Column({ type: 'jsonb', default: {} })
flags: 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
37 changes: 37 additions & 0 deletions src/migration/1754583578514-CampaignEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

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(), "endedAt" TIMESTAMP NOT NULL, "state" text NOT NULL, "flags" jsonb NOT NULL DEFAULT '{}', "postId" text, "sourceId" text, CONSTRAINT "PK_0ce34d26e7f2eb316a3a592cdc4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "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`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
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(`DROP INDEX "public"."IDX_campaign_type"`);
await queryRunner.query(`DROP TABLE "campaign"`);
}
}
133 changes: 133 additions & 0 deletions src/schema/campaigns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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';

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(first ?? 20);

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

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