Skip to content

Commit 3fd6c43

Browse files
committed
feat: recruiter organization seats
1 parent 22d8dd0 commit 3fd6c43

10 files changed

Lines changed: 320 additions & 64 deletions

File tree

seeds/ExperimentVariant.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"feature": "recruiter_pricing_ids",
2525
"variant": "recruiter_default",
2626
"createdAt": "2025-12-10T13:45:08.395Z",
27-
"value": "[{\"title\":\"Job Slot (Starter Tier)\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0p5g7qw8zb1e8esf7qjw2\",\"ios\":\"\"}},{\"title\":\"Job Slot (Boost Tier)\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0pv4mxbar7qt1nzs694r1\",\"ios\":\"\"}}]",
27+
"value": "[{\"title\":\"Starter\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0p5g7qw8zb1e8esf7qjw2\",\"ios\":\"\"}},{\"title\":\"Boost\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0pv4mxbar7qt1nzs694r1\",\"ios\":\"\"}}]",
2828
"type": "productPricing"
2929
}
3030
]

src/common/paddle/recruiter/processing.ts

Lines changed: 88 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import {
1717
ensureOpportunityPermissions,
1818
OpportunityPermissions,
1919
} from '../../opportunity/accessControl';
20-
import { updateSubscriptionFlags } from '../../utils';
20+
import { updateRecruiterSubscriptionFlags } from '../../utils';
21+
import { OpportunityState } from '@dailydotdev/schema';
22+
import { Organization } from '../../../entity/Organization';
2123

2224
export const createOpportunitySubscription = async ({
2325
event,
@@ -47,14 +49,33 @@ export const createOpportunitySubscription = async ({
4749
return false;
4850
}
4951

50-
const opportunity: Pick<OpportunityJob, 'id'> = await con
51-
.getRepository(OpportunityJob)
52-
.findOneOrFail({
53-
select: ['id'],
54-
where: {
55-
id: opportunity_id,
56-
},
57-
});
52+
const opportunity: Pick<
53+
OpportunityJob,
54+
'id' | 'organizationId' | 'organization'
55+
> = await con.getRepository(OpportunityJob).findOneOrFail({
56+
select: ['id', 'organizationId', 'organization'],
57+
where: {
58+
id: opportunity_id,
59+
},
60+
relations: {
61+
organization: true,
62+
},
63+
});
64+
65+
const organization = await opportunity.organization;
66+
67+
if (!organization) {
68+
throw new Error(
69+
'Opportunity does not have organization during payment processing, can not assign subscription, manual fixup needed',
70+
);
71+
}
72+
73+
if (
74+
organization.recruiterSubscriptionFlags?.status ===
75+
SubscriptionStatus.Active
76+
) {
77+
throw new Error('Organization already has active recruiter subscription');
78+
}
5879

5980
await ensureOpportunityPermissions({
6081
con: con.manager,
@@ -63,22 +84,38 @@ export const createOpportunitySubscription = async ({
6384
permission: OpportunityPermissions.Edit,
6485
});
6586

66-
await con.getRepository(OpportunityJob).update(
67-
{
68-
id: opportunity.id,
69-
},
70-
{
71-
subscriptionFlags: updateSubscriptionFlags<OpportunityJob>({
72-
cycle: subscriptionType,
73-
createdAt: data.startedAt ?? new Date(),
74-
updatedAt: new Date(),
75-
subscriptionId: data.id,
76-
priceId: data.items[0].price.id,
77-
provider: SubscriptionProvider.Paddle,
78-
status: SubscriptionStatus.Active,
79-
}),
80-
},
81-
);
87+
await con.transaction(async (entityManager) => {
88+
await entityManager.getRepository(Organization).update(
89+
{
90+
id: opportunity.id,
91+
},
92+
{
93+
recruiterSubscriptionFlags:
94+
updateRecruiterSubscriptionFlags<Organization>({
95+
cycle: subscriptionType,
96+
createdAt: data.startedAt ?? new Date(),
97+
updatedAt: new Date(),
98+
subscriptionId: data.id,
99+
provider: SubscriptionProvider.Paddle,
100+
status: SubscriptionStatus.Active,
101+
items: data.items.map((item) => {
102+
return {
103+
priceId: item.price.id,
104+
};
105+
}),
106+
}),
107+
},
108+
);
109+
110+
await entityManager.getRepository(OpportunityJob).update(
111+
{
112+
id: opportunity.id,
113+
},
114+
{
115+
state: OpportunityState.IN_REVIEW,
116+
},
117+
);
118+
});
82119
};
83120

84121
export const cancelRecruiterSubscription = async ({
@@ -91,14 +128,18 @@ export const cancelRecruiterSubscription = async ({
91128
event.data.customData,
92129
);
93130

94-
const opportunity: Pick<OpportunityJob, 'id'> = await con
95-
.getRepository(OpportunityJob)
96-
.findOneOrFail({
97-
select: ['id'],
98-
where: {
99-
id: opportunity_id,
100-
},
101-
});
131+
const opportunity: Pick<
132+
OpportunityJob,
133+
'id' | 'organizationId' | 'organization'
134+
> = await con.getRepository(OpportunityJob).findOneOrFail({
135+
select: ['id', 'organizationId', 'organization'],
136+
where: {
137+
id: opportunity_id,
138+
},
139+
relations: {
140+
organization: true,
141+
},
142+
});
102143

103144
await ensureOpportunityPermissions({
104145
con: con.manager,
@@ -107,19 +148,28 @@ export const cancelRecruiterSubscription = async ({
107148
permission: OpportunityPermissions.Edit,
108149
});
109150

110-
const subscriptionFlags: OpportunityJob['subscriptionFlags'] = {
151+
const organization = await opportunity.organization;
152+
153+
if (!organization) {
154+
throw new Error(
155+
'Opportunity does not have organization during payment processing, can not cancel subscription, manual fixup needed',
156+
);
157+
}
158+
159+
const subscriptionFlags: Organization['recruiterSubscriptionFlags'] = {
111160
cycle: null,
112-
status: SubscriptionStatus.Expired,
161+
status: SubscriptionStatus.Cancelled,
113162
updatedAt: new Date(),
163+
items: [],
114164
};
115165

116-
con.getRepository(OpportunityJob).update(
166+
con.getRepository(Organization).update(
117167
{
118168
id: opportunity.id,
119169
},
120170
{
121-
subscriptionFlags:
122-
updateSubscriptionFlags<OpportunityJob>(subscriptionFlags),
171+
recruiterSubscriptionFlags:
172+
updateRecruiterSubscriptionFlags<Organization>(subscriptionFlags),
123173
},
124174
);
125175
};

src/common/schema/opportunities.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,14 +272,11 @@ export const opportunityMatchesQuerySchema = z.object({
272272
first: z.number().optional(),
273273
});
274274

275-
export const opportunitySubscriptionFlagsSchema = z
275+
export const recruiterSubscriptionFlagsSchema = z
276276
.object({
277277
subscriptionId: z.string({
278278
error: 'Subscription ID is required',
279279
}),
280-
priceId: z.string({
281-
error: 'Price ID is required',
282-
}),
283280
cycle: z
284281
.enum(SubscriptionCycles, {
285282
error: 'Invalid subscription cycle',
@@ -293,10 +290,25 @@ export const opportunitySubscriptionFlagsSchema = z
293290
status: z.enum(SubscriptionStatus, {
294291
error: 'Invalid subscription status',
295292
}),
293+
items: z.array(
294+
z.object({
295+
priceId: z.string({
296+
error: 'Price ID is required',
297+
}),
298+
}),
299+
{
300+
error: 'At least one subscription item is required',
301+
},
302+
),
296303
})
297304
.partial();
298305

299306
export const gondulOpportunityPreviewResultSchema = z.object({
300307
user_ids: z.array(z.string()),
301308
total_count: z.number().int().nonnegative(),
302309
});
310+
311+
export const opportunityUpdateSubscriptionSchema = z.object({
312+
id: z.uuid(),
313+
priceId: z.string(),
314+
});

src/common/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,13 @@ export const textToSlug = (text: string): string =>
320320
locale: 'en',
321321
replacement: '-',
322322
}).substring(0, 100);
323+
324+
export const updateRecruiterSubscriptionFlags = <
325+
Entity extends {
326+
recruiterSubscriptionFlags: object;
327+
},
328+
>(
329+
update: Partial<Entity['recruiterSubscriptionFlags']>,
330+
): (() => string) => {
331+
return () => `recruiterSubscriptionFlags || '${JSON.stringify(update)}'`;
332+
};

src/entity/Organization.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
organizationSubscriptionFlagsSchema,
1515
} from '../common/schema/organizations';
1616
import type { CompanySize, CompanyStage } from '@dailydotdev/schema';
17+
import type { recruiterSubscriptionFlagsSchema } from '../common/schema/opportunities';
1718

1819
export type OrganizationLink = z.infer<typeof organizationLinksSchema>;
1920

@@ -87,4 +88,7 @@ export class Organization {
8788
{ lazy: true },
8889
)
8990
members: Promise<ContentPreferenceOrganization[]>;
91+
92+
@Column({ type: 'jsonb', default: {} })
93+
recruiterSubscriptionFlags: z.infer<typeof recruiterSubscriptionFlagsSchema>;
9094
}

src/entity/opportunities/Opportunity.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ import type { OpportunityKeyword } from '../OpportunityKeyword';
1919
import type { OpportunityMatch } from '../OpportunityMatch';
2020
import type { QuestionScreening } from '../questions/QuestionScreening';
2121
import type { QuestionFeedback } from '../questions/QuestionFeedback';
22-
import type { opportunitySubscriptionFlagsSchema } from '../../common/schema/opportunities';
23-
import type z from 'zod';
2422

2523
export type OpportunityFlags = Partial<{
2624
anonUserId: string | null;
@@ -29,9 +27,13 @@ export type OpportunityFlags = Partial<{
2927
totalCount: number;
3028
};
3129
batchSize: number;
30+
plan: string;
3231
}>;
3332

34-
export type OpportunityFlagsPublic = Pick<OpportunityFlags, 'batchSize'>;
33+
export type OpportunityFlagsPublic = Pick<
34+
OpportunityFlags,
35+
'batchSize' | 'plan'
36+
>;
3537

3638
@Entity()
3739
@TableInheritance({ column: { type: 'text', name: 'type' } })
@@ -109,7 +111,4 @@ export class Opportunity {
109111

110112
@Column({ type: 'jsonb', default: {} })
111113
flags: OpportunityFlags;
112-
113-
@Column({ type: 'jsonb', default: {} })
114-
subscriptionFlags: z.infer<typeof opportunitySubscriptionFlagsSchema>;
115114
}

src/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,12 @@ export class ParseCVProfileError extends Error {
218218
}
219219
}
220220
}
221+
222+
// Return 402 HTTP status code
223+
export class PaymentRequiredError extends ApolloError {
224+
constructor(message: string, extensions: Record<string, unknown> = {}) {
225+
super(message, 'PAYMENT_REQUIRED', extensions);
226+
227+
Object.defineProperty(this, 'name', { value: 'PaymentRequiredError' });
228+
}
229+
}

src/graphorm/index.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,7 @@ import { OrganizationLinkType } from '../common/schema/organizations';
7171
import type { GCSBlob } from '../common/schema/userCandidate';
7272
import { QuestionType } from '../entity/questions/types';
7373
import { snotraClient } from '../integrations/snotra';
74-
import type {
75-
Opportunity,
76-
OpportunityFlagsPublic,
77-
} from '../entity/opportunities/Opportunity';
78-
import { SubscriptionStatus } from '../common/plus';
74+
import type { OpportunityFlagsPublic } from '../entity/opportunities/Opportunity';
7975

8076
const existsByUserAndPost =
8177
(entity: string, build?: (queryBuilder: QueryBuilder) => QueryBuilder) =>
@@ -1586,19 +1582,12 @@ const obj = new GraphORM({
15861582
.orderBy(`${childAlias}."questionOrder"`, 'ASC'),
15871583
},
15881584
},
1589-
subscriptionStatus: {
1590-
select: 'subscriptionFlags',
1591-
transform: (
1592-
value: Opportunity['subscriptionFlags'],
1593-
): SubscriptionStatus => {
1594-
return value?.status || SubscriptionStatus.None;
1595-
},
1596-
},
15971585
flags: {
15981586
jsonType: true,
15991587
transform: (value: OpportunityFlagsPublic): OpportunityFlagsPublic => {
16001588
return {
16011589
batchSize: value?.batchSize ?? opportunityMatchBatchSize,
1590+
plan: value?.plan,
16021591
};
16031592
},
16041593
},
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class OrganizationRecruiterSubscriptionFlags1765808739042
4+
implements MigrationInterface
5+
{
6+
name = 'OrganizationRecruiterSubscriptionFlags1765808739042';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(
10+
`ALTER TABLE "opportunity" DROP COLUMN "subscriptionFlags"`,
11+
);
12+
await queryRunner.query(
13+
`ALTER TABLE "organization" ADD "recruiterSubscriptionFlags" jsonb NOT NULL DEFAULT '{}'`,
14+
);
15+
}
16+
17+
public async down(queryRunner: QueryRunner): Promise<void> {
18+
await queryRunner.query(
19+
`ALTER TABLE "organization" DROP COLUMN "recruiterSubscriptionFlags"`,
20+
);
21+
await queryRunner.query(
22+
`ALTER TABLE "opportunity" ADD "subscriptionFlags" jsonb NOT NULL DEFAULT '{}'`,
23+
);
24+
}
25+
}

0 commit comments

Comments
 (0)