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 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"))`,
);
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 IF EXISTS "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