@doccov/api 0.3.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/api/index.ts +12 -5
- package/migrations/001_initial.ts +151 -0
- package/migrations/002_polar_billing.ts +17 -0
- package/migrations/003_verification_table.ts +18 -0
- package/migrations/004_rename_tables_singular.ts +14 -0
- package/package.json +15 -4
- package/src/auth/config.ts +53 -0
- package/src/auth/hooks.ts +45 -0
- package/src/db/client.ts +15 -0
- package/src/db/migrate.ts +38 -0
- package/src/index.ts +63 -5
- package/src/middleware/anonymous-rate-limit.ts +131 -0
- package/src/middleware/api-key-auth.ts +94 -0
- package/src/middleware/org-rate-limit.ts +78 -0
- package/src/routes/ai.ts +353 -0
- package/src/routes/api-keys.ts +127 -0
- package/src/routes/auth.ts +62 -0
- package/src/routes/badge.ts +122 -32
- package/src/routes/billing.ts +267 -0
- package/src/routes/coverage.ts +288 -0
- package/src/routes/demo.ts +297 -0
- package/src/routes/github-app.ts +368 -0
- package/src/routes/invites.ts +90 -0
- package/src/routes/orgs.ts +387 -0
- package/src/routes/plan.ts +2 -72
- package/src/server.ts +22 -0
- package/src/utils/api-keys.ts +20 -0
- package/src/utils/github-app.ts +196 -0
- package/src/utils/github-checks.ts +278 -0
- package/src/utils/remote-analyzer.ts +251 -0
- package/vercel.json +1 -3
- package/src/utils/github.ts +0 -5
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
import { auth } from '../auth/config';
|
|
4
|
+
import { db } from '../db/client';
|
|
5
|
+
import { generateApiKey } from '../utils/api-keys';
|
|
6
|
+
|
|
7
|
+
export const apiKeysRoute = new Hono();
|
|
8
|
+
|
|
9
|
+
// List keys for org
|
|
10
|
+
apiKeysRoute.get('/', async (c) => {
|
|
11
|
+
const orgId = c.req.query('orgId');
|
|
12
|
+
if (!orgId) return c.json({ error: 'orgId required' }, 400);
|
|
13
|
+
|
|
14
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
15
|
+
if (!session) return c.json({ error: 'Unauthorized' }, 401);
|
|
16
|
+
|
|
17
|
+
const membership = await db
|
|
18
|
+
.selectFrom('org_members')
|
|
19
|
+
.where('orgId', '=', orgId)
|
|
20
|
+
.where('userId', '=', session.user.id)
|
|
21
|
+
.select('role')
|
|
22
|
+
.executeTakeFirst();
|
|
23
|
+
|
|
24
|
+
if (!membership) return c.json({ error: 'Not a member' }, 403);
|
|
25
|
+
|
|
26
|
+
const keys = await db
|
|
27
|
+
.selectFrom('api_keys')
|
|
28
|
+
.where('orgId', '=', orgId)
|
|
29
|
+
.select(['id', 'name', 'keyPrefix', 'lastUsedAt', 'expiresAt', 'createdAt'])
|
|
30
|
+
.orderBy('createdAt', 'desc')
|
|
31
|
+
.execute();
|
|
32
|
+
|
|
33
|
+
return c.json({ keys });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Create key (paid only)
|
|
37
|
+
apiKeysRoute.post('/', async (c) => {
|
|
38
|
+
const { orgId, name, expiresIn } = await c.req.json<{
|
|
39
|
+
orgId: string;
|
|
40
|
+
name: string;
|
|
41
|
+
expiresIn?: number;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
if (!orgId || !name) return c.json({ error: 'orgId and name required' }, 400);
|
|
45
|
+
|
|
46
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
47
|
+
if (!session) return c.json({ error: 'Unauthorized' }, 401);
|
|
48
|
+
|
|
49
|
+
const membership = await db
|
|
50
|
+
.selectFrom('org_members')
|
|
51
|
+
.innerJoin('organizations', 'organizations.id', 'org_members.orgId')
|
|
52
|
+
.where('org_members.orgId', '=', orgId)
|
|
53
|
+
.where('org_members.userId', '=', session.user.id)
|
|
54
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
55
|
+
.select(['org_members.role', 'organizations.plan'])
|
|
56
|
+
.executeTakeFirst();
|
|
57
|
+
|
|
58
|
+
if (!membership) return c.json({ error: 'Admin access required' }, 403);
|
|
59
|
+
|
|
60
|
+
if (membership.plan === 'free') {
|
|
61
|
+
return c.json(
|
|
62
|
+
{
|
|
63
|
+
error: 'API keys require a paid plan',
|
|
64
|
+
upgrade: 'https://doccov.com/pricing',
|
|
65
|
+
},
|
|
66
|
+
403,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { key, hash, prefix } = generateApiKey();
|
|
71
|
+
const id = nanoid(21);
|
|
72
|
+
const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) : null;
|
|
73
|
+
|
|
74
|
+
await db
|
|
75
|
+
.insertInto('api_keys')
|
|
76
|
+
.values({
|
|
77
|
+
id,
|
|
78
|
+
orgId,
|
|
79
|
+
name,
|
|
80
|
+
keyHash: hash,
|
|
81
|
+
keyPrefix: prefix,
|
|
82
|
+
expiresAt,
|
|
83
|
+
})
|
|
84
|
+
.execute();
|
|
85
|
+
|
|
86
|
+
return c.json(
|
|
87
|
+
{
|
|
88
|
+
id,
|
|
89
|
+
key, // Shown once!
|
|
90
|
+
name,
|
|
91
|
+
prefix,
|
|
92
|
+
expiresAt,
|
|
93
|
+
message: 'Save this key now. It cannot be retrieved again.',
|
|
94
|
+
},
|
|
95
|
+
201,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Revoke key
|
|
100
|
+
apiKeysRoute.delete('/:keyId', async (c) => {
|
|
101
|
+
const keyId = c.req.param('keyId');
|
|
102
|
+
|
|
103
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
104
|
+
if (!session) return c.json({ error: 'Unauthorized' }, 401);
|
|
105
|
+
|
|
106
|
+
const key = await db
|
|
107
|
+
.selectFrom('api_keys')
|
|
108
|
+
.where('id', '=', keyId)
|
|
109
|
+
.select(['id', 'orgId'])
|
|
110
|
+
.executeTakeFirst();
|
|
111
|
+
|
|
112
|
+
if (!key) return c.json({ error: 'Key not found' }, 404);
|
|
113
|
+
|
|
114
|
+
const membership = await db
|
|
115
|
+
.selectFrom('org_members')
|
|
116
|
+
.where('orgId', '=', key.orgId)
|
|
117
|
+
.where('userId', '=', session.user.id)
|
|
118
|
+
.where('role', 'in', ['owner', 'admin'])
|
|
119
|
+
.select('role')
|
|
120
|
+
.executeTakeFirst();
|
|
121
|
+
|
|
122
|
+
if (!membership) return c.json({ error: 'Admin access required' }, 403);
|
|
123
|
+
|
|
124
|
+
await db.deleteFrom('api_keys').where('id', '=', keyId).execute();
|
|
125
|
+
|
|
126
|
+
return c.json({ deleted: true });
|
|
127
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { auth } from '../auth/config';
|
|
3
|
+
import { createPersonalOrg } from '../auth/hooks';
|
|
4
|
+
import { db } from '../db/client';
|
|
5
|
+
|
|
6
|
+
export const authRoute = new Hono();
|
|
7
|
+
|
|
8
|
+
// Custom routes BEFORE better-auth catch-all
|
|
9
|
+
|
|
10
|
+
// Get current session with orgs
|
|
11
|
+
authRoute.get('/session', async (c) => {
|
|
12
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
13
|
+
|
|
14
|
+
if (!session) {
|
|
15
|
+
return c.json({ user: null, session: null, organizations: [] });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Get user's orgs
|
|
19
|
+
const memberships = await db
|
|
20
|
+
.selectFrom('org_members')
|
|
21
|
+
.innerJoin('organizations', 'organizations.id', 'org_members.orgId')
|
|
22
|
+
.where('org_members.userId', '=', session.user.id)
|
|
23
|
+
.select([
|
|
24
|
+
'organizations.id',
|
|
25
|
+
'organizations.name',
|
|
26
|
+
'organizations.slug',
|
|
27
|
+
'organizations.plan',
|
|
28
|
+
'organizations.isPersonal',
|
|
29
|
+
'org_members.role',
|
|
30
|
+
])
|
|
31
|
+
.execute();
|
|
32
|
+
|
|
33
|
+
return c.json({
|
|
34
|
+
user: session.user,
|
|
35
|
+
session: session.session,
|
|
36
|
+
organizations: memberships,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Webhook for post-signup actions
|
|
41
|
+
authRoute.post('/webhook/user-created', async (c) => {
|
|
42
|
+
const { userId, email, name } = await c.req.json();
|
|
43
|
+
|
|
44
|
+
// Check if user already has an org
|
|
45
|
+
const existingMembership = await db
|
|
46
|
+
.selectFrom('org_members')
|
|
47
|
+
.where('userId', '=', userId)
|
|
48
|
+
.select('id')
|
|
49
|
+
.executeTakeFirst();
|
|
50
|
+
|
|
51
|
+
if (!existingMembership) {
|
|
52
|
+
await createPersonalOrg(userId, name, email);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return c.json({ ok: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Better Auth handles all other routes
|
|
59
|
+
authRoute.on(['GET', 'POST'], '/*', async (c) => {
|
|
60
|
+
const response = await auth.handler(c.req.raw);
|
|
61
|
+
return response;
|
|
62
|
+
});
|
package/src/routes/badge.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { fetchSpec } from '@doccov/sdk';
|
|
2
|
+
import { validateSpec } from '@openpkg-ts/spec';
|
|
1
3
|
import { Hono } from 'hono';
|
|
2
|
-
import { fetchSpecFromGitHub } from '../utils/github';
|
|
3
4
|
|
|
4
5
|
export const badgeRoute = new Hono();
|
|
5
6
|
|
|
7
|
+
type BadgeStyle = 'flat' | 'flat-square' | 'for-the-badge';
|
|
8
|
+
|
|
6
9
|
type BadgeColor =
|
|
7
10
|
| 'brightgreen'
|
|
8
11
|
| 'green'
|
|
@@ -16,6 +19,7 @@ interface BadgeOptions {
|
|
|
16
19
|
label: string;
|
|
17
20
|
message: string;
|
|
18
21
|
color: BadgeColor;
|
|
22
|
+
style?: BadgeStyle;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
function getColorForScore(score: number): BadgeColor {
|
|
@@ -27,22 +31,17 @@ function getColorForScore(score: number): BadgeColor {
|
|
|
27
31
|
return 'red';
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const bgColor = colors[color];
|
|
44
|
-
|
|
45
|
-
// Simple badge dimensions
|
|
34
|
+
const BADGE_COLORS: Record<BadgeColor, string> = {
|
|
35
|
+
brightgreen: '#4c1',
|
|
36
|
+
green: '#97ca00',
|
|
37
|
+
yellowgreen: '#a4a61d',
|
|
38
|
+
yellow: '#dfb317',
|
|
39
|
+
orange: '#fe7d37',
|
|
40
|
+
red: '#e05d44',
|
|
41
|
+
lightgrey: '#9f9f9f',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function generateFlatBadge(label: string, message: string, bgColor: string): string {
|
|
46
45
|
const labelWidth = label.length * 7 + 10;
|
|
47
46
|
const messageWidth = message.length * 7 + 10;
|
|
48
47
|
const totalWidth = labelWidth + messageWidth;
|
|
@@ -70,49 +69,139 @@ function generateBadgeSvg(options: BadgeOptions): string {
|
|
|
70
69
|
</svg>`;
|
|
71
70
|
}
|
|
72
71
|
|
|
72
|
+
function generateFlatSquareBadge(label: string, message: string, bgColor: string): string {
|
|
73
|
+
const labelWidth = label.length * 7 + 10;
|
|
74
|
+
const messageWidth = message.length * 7 + 10;
|
|
75
|
+
const totalWidth = labelWidth + messageWidth;
|
|
76
|
+
|
|
77
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${message}">
|
|
78
|
+
<title>${label}: ${message}</title>
|
|
79
|
+
<g shape-rendering="crispEdges">
|
|
80
|
+
<rect width="${labelWidth}" height="20" fill="#555"/>
|
|
81
|
+
<rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${bgColor}"/>
|
|
82
|
+
</g>
|
|
83
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
|
84
|
+
<text x="${labelWidth / 2}" y="14">${label}</text>
|
|
85
|
+
<text x="${labelWidth + messageWidth / 2}" y="14">${message}</text>
|
|
86
|
+
</g>
|
|
87
|
+
</svg>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function generateForTheBadge(label: string, message: string, bgColor: string): string {
|
|
91
|
+
const labelUpper = label.toUpperCase();
|
|
92
|
+
const messageUpper = message.toUpperCase();
|
|
93
|
+
const labelWidth = labelUpper.length * 10 + 20;
|
|
94
|
+
const messageWidth = messageUpper.length * 10 + 20;
|
|
95
|
+
const totalWidth = labelWidth + messageWidth;
|
|
96
|
+
|
|
97
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="28" role="img" aria-label="${label}: ${message}">
|
|
98
|
+
<title>${label}: ${message}</title>
|
|
99
|
+
<g shape-rendering="crispEdges">
|
|
100
|
+
<rect width="${labelWidth}" height="28" fill="#555"/>
|
|
101
|
+
<rect x="${labelWidth}" width="${messageWidth}" height="28" fill="${bgColor}"/>
|
|
102
|
+
</g>
|
|
103
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="10" font-weight="bold">
|
|
104
|
+
<text x="${labelWidth / 2}" y="18">${labelUpper}</text>
|
|
105
|
+
<text x="${labelWidth + messageWidth / 2}" y="18">${messageUpper}</text>
|
|
106
|
+
</g>
|
|
107
|
+
</svg>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function generateBadgeSvg(options: BadgeOptions): string {
|
|
111
|
+
const { label, message, color, style = 'flat' } = options;
|
|
112
|
+
const bgColor = BADGE_COLORS[color];
|
|
113
|
+
|
|
114
|
+
switch (style) {
|
|
115
|
+
case 'flat-square':
|
|
116
|
+
return generateFlatSquareBadge(label, message, bgColor);
|
|
117
|
+
case 'for-the-badge':
|
|
118
|
+
return generateForTheBadge(label, message, bgColor);
|
|
119
|
+
default:
|
|
120
|
+
return generateFlatBadge(label, message, bgColor);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compute coverage score from spec exports if not already present.
|
|
126
|
+
*/
|
|
127
|
+
function computeCoverageScore(spec: { exports?: { description?: string }[] }): number {
|
|
128
|
+
const exports = spec.exports ?? [];
|
|
129
|
+
if (exports.length === 0) return 0;
|
|
130
|
+
|
|
131
|
+
const documented = exports.filter((e) => e.description && e.description.trim().length > 0);
|
|
132
|
+
return Math.round((documented.length / exports.length) * 100);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cache headers: 5min max-age, stale-if-error for resilience
|
|
136
|
+
const CACHE_HEADERS_SUCCESS = {
|
|
137
|
+
'Content-Type': 'image/svg+xml',
|
|
138
|
+
'Cache-Control': 'public, max-age=300, stale-if-error=3600',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const CACHE_HEADERS_ERROR = {
|
|
142
|
+
'Content-Type': 'image/svg+xml',
|
|
143
|
+
'Cache-Control': 'no-cache',
|
|
144
|
+
};
|
|
145
|
+
|
|
73
146
|
// GET /badge/:owner/:repo
|
|
74
147
|
badgeRoute.get('/:owner/:repo', async (c) => {
|
|
75
148
|
const { owner, repo } = c.req.param();
|
|
76
|
-
|
|
149
|
+
|
|
150
|
+
// Query params for customization
|
|
151
|
+
const ref = c.req.query('ref') ?? c.req.query('branch') ?? 'main';
|
|
152
|
+
const specPath = c.req.query('path') ?? c.req.query('package') ?? 'openpkg.json';
|
|
153
|
+
const style = (c.req.query('style') ?? 'flat') as BadgeStyle;
|
|
77
154
|
|
|
78
155
|
try {
|
|
79
|
-
const spec = await
|
|
156
|
+
const spec = await fetchSpec(owner, repo, { ref, path: specPath });
|
|
80
157
|
|
|
81
158
|
if (!spec) {
|
|
82
159
|
const svg = generateBadgeSvg({
|
|
83
160
|
label: 'docs',
|
|
84
161
|
message: 'not found',
|
|
85
162
|
color: 'lightgrey',
|
|
163
|
+
style,
|
|
86
164
|
});
|
|
87
165
|
|
|
88
|
-
return c.body(svg, 404,
|
|
89
|
-
|
|
90
|
-
|
|
166
|
+
return c.body(svg, 404, CACHE_HEADERS_ERROR);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Validate spec against schema
|
|
170
|
+
const validation = validateSpec(spec);
|
|
171
|
+
if (!validation.ok) {
|
|
172
|
+
const svg = generateBadgeSvg({
|
|
173
|
+
label: 'docs',
|
|
174
|
+
message: 'invalid',
|
|
175
|
+
color: 'lightgrey',
|
|
176
|
+
style,
|
|
91
177
|
});
|
|
178
|
+
|
|
179
|
+
return c.body(svg, 422, CACHE_HEADERS_ERROR);
|
|
92
180
|
}
|
|
93
181
|
|
|
94
|
-
|
|
182
|
+
// Use docs.coverageScore if present (enriched spec), otherwise compute from exports
|
|
183
|
+
// Note: The spec type has changed - check for generation.analysis or similar patterns
|
|
184
|
+
const coverageScore =
|
|
185
|
+
(spec as { docs?: { coverageScore?: number } }).docs?.coverageScore ??
|
|
186
|
+
computeCoverageScore(spec);
|
|
187
|
+
|
|
95
188
|
const svg = generateBadgeSvg({
|
|
96
189
|
label: 'docs',
|
|
97
190
|
message: `${coverageScore}%`,
|
|
98
191
|
color: getColorForScore(coverageScore),
|
|
192
|
+
style,
|
|
99
193
|
});
|
|
100
194
|
|
|
101
|
-
return c.body(svg, 200,
|
|
102
|
-
'Content-Type': 'image/svg+xml',
|
|
103
|
-
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
|
104
|
-
});
|
|
195
|
+
return c.body(svg, 200, CACHE_HEADERS_SUCCESS);
|
|
105
196
|
} catch {
|
|
106
197
|
const svg = generateBadgeSvg({
|
|
107
198
|
label: 'docs',
|
|
108
199
|
message: 'error',
|
|
109
200
|
color: 'red',
|
|
201
|
+
style,
|
|
110
202
|
});
|
|
111
203
|
|
|
112
|
-
return c.body(svg, 500,
|
|
113
|
-
'Content-Type': 'image/svg+xml',
|
|
114
|
-
'Cache-Control': 'no-cache',
|
|
115
|
-
});
|
|
204
|
+
return c.body(svg, 500, CACHE_HEADERS_ERROR);
|
|
116
205
|
}
|
|
117
206
|
});
|
|
118
207
|
|
|
@@ -120,5 +209,6 @@ badgeRoute.get('/:owner/:repo', async (c) => {
|
|
|
120
209
|
badgeRoute.get('/:owner/:repo.svg', async (c) => {
|
|
121
210
|
const { owner, repo } = c.req.param();
|
|
122
211
|
const repoName = repo.replace(/\.svg$/, '');
|
|
123
|
-
|
|
212
|
+
const query = new URL(c.req.url).search;
|
|
213
|
+
return c.redirect(`/badge/${owner}/${repoName}${query}`);
|
|
124
214
|
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { getPlanLimits, type Plan } from '@doccov/db';
|
|
2
|
+
import { CustomerPortal, Webhooks } from '@polar-sh/hono';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { auth } from '../auth/config';
|
|
5
|
+
import { db } from '../db/client';
|
|
6
|
+
|
|
7
|
+
export const billingRoute = new Hono();
|
|
8
|
+
|
|
9
|
+
const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN!;
|
|
10
|
+
const POLAR_WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!;
|
|
11
|
+
const SITE_URL = process.env.SITE_URL || 'http://localhost:3000';
|
|
12
|
+
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
|
13
|
+
|
|
14
|
+
const PRODUCTS = {
|
|
15
|
+
team: process.env.POLAR_PRODUCT_TEAM!,
|
|
16
|
+
pro: process.env.POLAR_PRODUCT_PRO!,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const POLAR_API =
|
|
20
|
+
process.env.NODE_ENV === 'production' ? 'https://api.polar.sh' : 'https://sandbox-api.polar.sh';
|
|
21
|
+
|
|
22
|
+
// ============ Checkout ============
|
|
23
|
+
billingRoute.get('/checkout', async (c) => {
|
|
24
|
+
const plan = c.req.query('plan') as 'team' | 'pro';
|
|
25
|
+
const orgId = c.req.query('orgId');
|
|
26
|
+
|
|
27
|
+
if (!plan || !PRODUCTS[plan]) {
|
|
28
|
+
return c.json({ error: 'Invalid plan' }, 400);
|
|
29
|
+
}
|
|
30
|
+
if (!orgId) {
|
|
31
|
+
return c.json({ error: 'orgId required' }, 400);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
35
|
+
if (!session) {
|
|
36
|
+
return c.redirect(`${SITE_URL}/login?callbackUrl=/pricing`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Verify user is owner/admin of org
|
|
40
|
+
const membership = await db
|
|
41
|
+
.selectFrom('org_members')
|
|
42
|
+
.where('orgId', '=', orgId)
|
|
43
|
+
.where('userId', '=', session.user.id)
|
|
44
|
+
.where('role', 'in', ['owner', 'admin'])
|
|
45
|
+
.select('id')
|
|
46
|
+
.executeTakeFirst();
|
|
47
|
+
|
|
48
|
+
if (!membership) {
|
|
49
|
+
return c.json({ error: 'Not authorized' }, 403);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Create Polar checkout session via API
|
|
53
|
+
const res = await fetch(`${POLAR_API}/v1/checkouts/`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
Authorization: `Bearer ${POLAR_ACCESS_TOKEN}`,
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
products: [PRODUCTS[plan]],
|
|
61
|
+
success_url: `${SITE_URL}/dashboard?upgraded=true`,
|
|
62
|
+
metadata: { orgId, plan },
|
|
63
|
+
customer_email: session.user.email,
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const error = await res.text();
|
|
69
|
+
console.error('Polar checkout error:', error);
|
|
70
|
+
return c.json({ error: 'Failed to create checkout session' }, 500);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const checkout = await res.json();
|
|
74
|
+
return c.redirect(checkout.url);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ============ Customer Portal ============
|
|
78
|
+
billingRoute.get('/portal', async (c) => {
|
|
79
|
+
const orgId = c.req.query('orgId');
|
|
80
|
+
if (!orgId) return c.json({ error: 'orgId required' }, 400);
|
|
81
|
+
|
|
82
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
83
|
+
if (!session) return c.redirect(`${SITE_URL}/login`);
|
|
84
|
+
|
|
85
|
+
const org = await db
|
|
86
|
+
.selectFrom('organizations')
|
|
87
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
88
|
+
.where('organizations.id', '=', orgId)
|
|
89
|
+
.where('org_members.userId', '=', session.user.id)
|
|
90
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
91
|
+
.select(['organizations.polarCustomerId'])
|
|
92
|
+
.executeTakeFirst();
|
|
93
|
+
|
|
94
|
+
if (!org?.polarCustomerId) {
|
|
95
|
+
return c.json({ error: 'No billing account found' }, 404);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const portalHandler = CustomerPortal({
|
|
99
|
+
accessToken: POLAR_ACCESS_TOKEN,
|
|
100
|
+
getCustomerId: async () => org.polarCustomerId!,
|
|
101
|
+
server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return portalHandler(c);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ============ Webhooks ============
|
|
108
|
+
billingRoute.post(
|
|
109
|
+
'/webhook',
|
|
110
|
+
Webhooks({
|
|
111
|
+
webhookSecret: POLAR_WEBHOOK_SECRET,
|
|
112
|
+
|
|
113
|
+
onSubscriptionActive: async (payload) => {
|
|
114
|
+
const subscription = payload.data;
|
|
115
|
+
const metadata = subscription.metadata as { orgId?: string; plan?: string };
|
|
116
|
+
|
|
117
|
+
if (!metadata?.orgId || !metadata?.plan) {
|
|
118
|
+
console.error('Missing metadata:', subscription.id);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await db
|
|
123
|
+
.updateTable('organizations')
|
|
124
|
+
.set({
|
|
125
|
+
plan: metadata.plan as 'team' | 'pro',
|
|
126
|
+
polarCustomerId: subscription.customerId,
|
|
127
|
+
polarSubscriptionId: subscription.id,
|
|
128
|
+
})
|
|
129
|
+
.where('id', '=', metadata.orgId)
|
|
130
|
+
.execute();
|
|
131
|
+
|
|
132
|
+
console.log(`Org ${metadata.orgId} upgraded to ${metadata.plan}`);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
onSubscriptionCanceled: async (payload) => {
|
|
136
|
+
const subscription = payload.data;
|
|
137
|
+
|
|
138
|
+
const org = await db
|
|
139
|
+
.selectFrom('organizations')
|
|
140
|
+
.where('polarSubscriptionId', '=', subscription.id)
|
|
141
|
+
.select('id')
|
|
142
|
+
.executeTakeFirst();
|
|
143
|
+
|
|
144
|
+
if (org) {
|
|
145
|
+
await db
|
|
146
|
+
.updateTable('organizations')
|
|
147
|
+
.set({ plan: 'free' })
|
|
148
|
+
.where('id', '=', org.id)
|
|
149
|
+
.execute();
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
onSubscriptionRevoked: async (payload) => {
|
|
154
|
+
const subscription = payload.data;
|
|
155
|
+
|
|
156
|
+
const org = await db
|
|
157
|
+
.selectFrom('organizations')
|
|
158
|
+
.where('polarSubscriptionId', '=', subscription.id)
|
|
159
|
+
.select('id')
|
|
160
|
+
.executeTakeFirst();
|
|
161
|
+
|
|
162
|
+
if (org) {
|
|
163
|
+
await db
|
|
164
|
+
.updateTable('organizations')
|
|
165
|
+
.set({ plan: 'free', polarSubscriptionId: null })
|
|
166
|
+
.where('id', '=', org.id)
|
|
167
|
+
.execute();
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// ============ Status ============
|
|
174
|
+
billingRoute.get('/status', async (c) => {
|
|
175
|
+
const orgId = c.req.query('orgId');
|
|
176
|
+
if (!orgId) return c.json({ error: 'orgId required' }, 400);
|
|
177
|
+
|
|
178
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
179
|
+
if (!session) return c.json({ error: 'Unauthorized' }, 401);
|
|
180
|
+
|
|
181
|
+
const org = await db
|
|
182
|
+
.selectFrom('organizations')
|
|
183
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
184
|
+
.where('organizations.id', '=', orgId)
|
|
185
|
+
.where('org_members.userId', '=', session.user.id)
|
|
186
|
+
.select([
|
|
187
|
+
'organizations.plan',
|
|
188
|
+
'organizations.polarCustomerId',
|
|
189
|
+
'organizations.polarSubscriptionId',
|
|
190
|
+
'organizations.aiCallsUsed',
|
|
191
|
+
'organizations.aiCallsResetAt',
|
|
192
|
+
])
|
|
193
|
+
.executeTakeFirst();
|
|
194
|
+
|
|
195
|
+
if (!org) return c.json({ error: 'Organization not found' }, 404);
|
|
196
|
+
|
|
197
|
+
return c.json({
|
|
198
|
+
plan: org.plan,
|
|
199
|
+
hasSubscription: !!org.polarSubscriptionId,
|
|
200
|
+
usage: { aiCalls: org.aiCallsUsed, resetAt: org.aiCallsResetAt },
|
|
201
|
+
portalUrl: org.polarCustomerId ? `${API_URL}/billing/portal?orgId=${orgId}` : null,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ============ Usage Details ============
|
|
206
|
+
billingRoute.get('/usage', async (c) => {
|
|
207
|
+
const orgId = c.req.query('orgId');
|
|
208
|
+
if (!orgId) return c.json({ error: 'orgId required' }, 400);
|
|
209
|
+
|
|
210
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
211
|
+
if (!session) return c.json({ error: 'Unauthorized' }, 401);
|
|
212
|
+
|
|
213
|
+
// Get org with member count
|
|
214
|
+
const org = await db
|
|
215
|
+
.selectFrom('organizations')
|
|
216
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
217
|
+
.where('organizations.id', '=', orgId)
|
|
218
|
+
.where('org_members.userId', '=', session.user.id)
|
|
219
|
+
.select([
|
|
220
|
+
'organizations.id',
|
|
221
|
+
'organizations.plan',
|
|
222
|
+
'organizations.aiCallsUsed',
|
|
223
|
+
'organizations.aiCallsResetAt',
|
|
224
|
+
])
|
|
225
|
+
.executeTakeFirst();
|
|
226
|
+
|
|
227
|
+
if (!org) return c.json({ error: 'Organization not found' }, 404);
|
|
228
|
+
|
|
229
|
+
// Get member count
|
|
230
|
+
const memberResult = await db
|
|
231
|
+
.selectFrom('org_members')
|
|
232
|
+
.where('orgId', '=', orgId)
|
|
233
|
+
.select(db.fn.countAll<number>().as('count'))
|
|
234
|
+
.executeTakeFirst();
|
|
235
|
+
|
|
236
|
+
const seats = memberResult?.count ?? 1;
|
|
237
|
+
const limits = getPlanLimits(org.plan as Plan);
|
|
238
|
+
|
|
239
|
+
// Calculate next reset date
|
|
240
|
+
const now = new Date();
|
|
241
|
+
const resetAt = org.aiCallsResetAt || new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
242
|
+
const shouldReset = !org.aiCallsResetAt || now >= org.aiCallsResetAt;
|
|
243
|
+
const aiUsed = shouldReset ? 0 : org.aiCallsUsed;
|
|
244
|
+
|
|
245
|
+
// Calculate pricing based on plan
|
|
246
|
+
const pricing: Record<string, number> = { free: 0, team: 15, pro: 49 };
|
|
247
|
+
const monthlyCost = (pricing[org.plan] ?? 0) * seats;
|
|
248
|
+
|
|
249
|
+
return c.json({
|
|
250
|
+
plan: org.plan,
|
|
251
|
+
seats,
|
|
252
|
+
monthlyCost,
|
|
253
|
+
aiCalls: {
|
|
254
|
+
used: aiUsed,
|
|
255
|
+
limit: limits.aiCallsPerMonth === Infinity ? 'unlimited' : limits.aiCallsPerMonth,
|
|
256
|
+
resetAt: resetAt.toISOString(),
|
|
257
|
+
},
|
|
258
|
+
analyses: {
|
|
259
|
+
limit: limits.analysesPerDay === Infinity ? 'unlimited' : limits.analysesPerDay,
|
|
260
|
+
resetAt: 'daily',
|
|
261
|
+
},
|
|
262
|
+
history: {
|
|
263
|
+
days: limits.historyDays,
|
|
264
|
+
},
|
|
265
|
+
privateRepos: limits.privateRepos === Infinity,
|
|
266
|
+
});
|
|
267
|
+
});
|