Skip to content

Commit 3be3680

Browse files
authored
cleanup org's repos and shards if it's inactive (#194)
* add stripe subscription status and webhook * add inactive org repo cleanup logic * mark reactivated org connections for sync
1 parent 86a80a4 commit 3be3680

File tree

6 files changed

+151
-5
lines changed

6 files changed

+151
-5
lines changed

packages/backend/src/repoManager.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Job, Queue, Worker } from 'bullmq';
22
import { Redis } from 'ioredis';
33
import { createLogger } from "./logger.js";
4-
import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus } from "@sourcebot/db";
4+
import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
55
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
66
import { AppContext, Settings } from "./types.js";
77
import { captureEvent } from "./posthog.js";
@@ -106,8 +106,33 @@ export class RepoManager implements IRepoManager {
106106
}
107107
});
108108

109-
for (const repo of reposWithNoConnections) {
110-
this.logger.info(`Garbage collecting repo with no connections: ${repo.id}`);
109+
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
110+
const inactiveOrgs = await this.db.org.findMany({
111+
where: {
112+
stripeSubscriptionStatus: StripeSubscriptionStatus.INACTIVE,
113+
stripeLastUpdatedAt: {
114+
lt: sevenDaysAgo
115+
}
116+
}
117+
});
118+
119+
const inactiveOrgIds = inactiveOrgs.map(org => org.id);
120+
121+
const inactiveOrgRepos = await this.db.repo.findMany({
122+
where: {
123+
orgId: {
124+
in: inactiveOrgIds
125+
}
126+
}
127+
});
128+
129+
if (inactiveOrgIds.length > 0 && inactiveOrgRepos.length > 0) {
130+
console.log(`Garbage collecting ${inactiveOrgs.length} inactive orgs: ${inactiveOrgIds.join(', ')}`);
131+
}
132+
133+
const reposToDelete = [...reposWithNoConnections, ...inactiveOrgRepos];
134+
for (const repo of reposToDelete) {
135+
this.logger.info(`Garbage collecting repo: ${repo.id}`);
111136

112137
// delete cloned repo
113138
const repoPath = getRepoPath(repo, this.ctx);
@@ -129,7 +154,7 @@ export class RepoManager implements IRepoManager {
129154
await this.db.repo.deleteMany({
130155
where: {
131156
id: {
132-
in: reposWithNoConnections.map(repo => repo.id)
157+
in: reposToDelete.map(repo => repo.id)
133158
}
134159
}
135160
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- CreateEnum
2+
CREATE TYPE "StripeSubscriptionStatus" AS ENUM ('ACTIVE', 'INACTIVE');
3+
4+
-- AlterTable
5+
ALTER TABLE "Org" ADD COLUMN "stripeLastUpdatedAt" TIMESTAMP(3),
6+
ADD COLUMN "stripeSubscriptionStatus" "StripeSubscriptionStatus";

packages/db/prisma/schema.prisma

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ enum ConnectionSyncStatus {
2626
FAILED
2727
}
2828

29+
enum StripeSubscriptionStatus {
30+
ACTIVE
31+
INACTIVE
32+
}
33+
2934
model Repo {
3035
id Int @id @default(autoincrement())
3136
name String
@@ -115,7 +120,9 @@ model Org {
115120
repos Repo[]
116121
secrets Secret[]
117122
118-
stripeCustomerId String?
123+
stripeCustomerId String?
124+
stripeSubscriptionStatus StripeSubscriptionStatus?
125+
stripeLastUpdatedAt DateTime?
119126
120127
/// List of pending invites to this organization
121128
invites Invite[]

packages/web/src/actions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { getStripe } from "@/lib/stripe"
2020
import { getUser } from "@/data/user";
2121
import { Session } from "next-auth";
2222
import { STRIPE_PRODUCT_ID } from "@/lib/environment";
23+
import { StripeSubscriptionStatus } from "@sourcebot/db";
2324
import Stripe from "stripe";
2425
const ajv = new Ajv({
2526
validateFormats: false,
@@ -103,6 +104,8 @@ export const createOrg = (name: string, domain: string, stripeCustomerId?: strin
103104
name,
104105
domain,
105106
stripeCustomerId,
107+
stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE,
108+
stripeLastUpdatedAt: new Date(),
106109
members: {
107110
create: {
108111
role: "OWNER",
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { headers } from 'next/headers';
2+
import { NextRequest } from 'next/server';
3+
import Stripe from 'stripe';
4+
import { prisma } from '@/prisma';
5+
import { STRIPE_WEBHOOK_SECRET } from '@/lib/environment';
6+
import { getStripe } from '@/lib/stripe';
7+
import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db';
8+
export async function POST(req: NextRequest) {
9+
const body = await req.text();
10+
const signature = headers().get('stripe-signature');
11+
12+
if (!signature) {
13+
return new Response('No signature', { status: 400 });
14+
}
15+
16+
try {
17+
const stripe = getStripe();
18+
const event = stripe.webhooks.constructEvent(
19+
body,
20+
signature,
21+
STRIPE_WEBHOOK_SECRET!
22+
);
23+
24+
if (event.type === 'customer.subscription.deleted') {
25+
const subscription = event.data.object as Stripe.Subscription;
26+
const customerId = subscription.customer as string;
27+
28+
const org = await prisma.org.findFirst({
29+
where: {
30+
stripeCustomerId: customerId
31+
}
32+
});
33+
34+
if (!org) {
35+
return new Response('Org not found', { status: 404 });
36+
}
37+
38+
await prisma.org.update({
39+
where: {
40+
id: org.id
41+
},
42+
data: {
43+
stripeSubscriptionStatus: StripeSubscriptionStatus.INACTIVE,
44+
stripeLastUpdatedAt: new Date()
45+
}
46+
});
47+
console.log(`Org ${org.id} subscription status updated to INACTIVE`);
48+
49+
return new Response(JSON.stringify({ received: true }), {
50+
status: 200
51+
});
52+
} else if (event.type === 'customer.subscription.created') {
53+
const subscription = event.data.object as Stripe.Subscription;
54+
const customerId = subscription.customer as string;
55+
56+
const org = await prisma.org.findFirst({
57+
where: {
58+
stripeCustomerId: customerId
59+
}
60+
});
61+
62+
if (!org) {
63+
return new Response('Org not found', { status: 404 });
64+
}
65+
66+
await prisma.org.update({
67+
where: {
68+
id: org.id
69+
},
70+
data: {
71+
stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE,
72+
stripeLastUpdatedAt: new Date()
73+
}
74+
});
75+
console.log(`Org ${org.id} subscription status updated to ACTIVE`);
76+
77+
// mark all of this org's connections for sync, since their repos may have been previously garbage collected
78+
await prisma.connection.updateMany({
79+
where: {
80+
orgId: org.id
81+
},
82+
data: {
83+
syncStatus: ConnectionSyncStatus.SYNC_NEEDED
84+
}
85+
});
86+
87+
return new Response(JSON.stringify({ received: true }), {
88+
status: 200
89+
});
90+
} else {
91+
console.log(`Received unknown event type: ${event.type}`);
92+
return new Response(JSON.stringify({ received: true }), {
93+
status: 202
94+
});
95+
}
96+
97+
} catch (err) {
98+
console.error('Error processing webhook:', err);
99+
return new Response(
100+
'Webhook error: ' + (err as Error).message,
101+
{ status: 400 }
102+
);
103+
}
104+
}

packages/web/src/lib/environment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export const AUTH_URL = getEnv(process.env.AUTH_URL)!;
1616

1717
export const STRIPE_SECRET_KEY = getEnv(process.env.STRIPE_SECRET_KEY);
1818
export const STRIPE_PRODUCT_ID = getEnv(process.env.STRIPE_PRODUCT_ID);
19+
export const STRIPE_WEBHOOK_SECRET = getEnv(process.env.STRIPE_WEBHOOK_SECRET);

0 commit comments

Comments
 (0)