@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.
@@ -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
+ });
@@ -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
- function generateBadgeSvg(options: BadgeOptions): string {
31
- const { label, message, color } = options;
32
-
33
- const colors: Record<BadgeColor, string> = {
34
- brightgreen: '#4c1',
35
- green: '#97ca00',
36
- yellowgreen: '#a4a61d',
37
- yellow: '#dfb317',
38
- orange: '#fe7d37',
39
- red: '#e05d44',
40
- lightgrey: '#9f9f9f',
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
- const branch = c.req.query('branch') ?? 'main';
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 fetchSpecFromGitHub(owner, repo, branch);
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
- 'Content-Type': 'image/svg+xml',
90
- 'Cache-Control': 'no-cache',
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
- const coverageScore = spec.docs?.coverageScore ?? 0;
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
- return c.redirect(`/badge/${owner}/${repoName}`);
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
+ });