Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ AUTH_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=

# Google Calendar integration. OAuth Web client (Calendar API enabled) with
# redirect URI http://localhost:3002/v1/integrations/google/callback
# Google integrations (Calendar + Play Books). One OAuth Web client, with both
# APIs enabled and both redirect URIs registered:
# http://localhost:3002/v1/integrations/google/callback
# http://localhost:3002/v1/integrations/google-books/callback
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=

Expand Down
2 changes: 2 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ COPY packages/events/package.json ./packages/events/package.json
COPY packages/metrics/package.json ./packages/metrics/package.json
COPY packages/integrations/github/package.json ./packages/integrations/github/package.json
COPY packages/integrations/google-calendar/package.json ./packages/integrations/google-calendar/package.json
COPY packages/integrations/google-books/package.json ./packages/integrations/google-books/package.json

RUN pnpm install --frozen-lockfile --filter @baseline/api...

Expand All @@ -25,6 +26,7 @@ COPY packages/events ./packages/events
COPY packages/metrics ./packages/metrics
COPY packages/integrations/github ./packages/integrations/github
COPY packages/integrations/google-calendar ./packages/integrations/google-calendar
COPY packages/integrations/google-books ./packages/integrations/google-books
COPY tsconfig.base.json turbo.json ./

ENV NEXT_TELEMETRY_DISABLED=1
Expand Down
17 changes: 15 additions & 2 deletions apps/api/app/v1/integrations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, integrations } from '@baseline/db';
import { eq, and } from 'drizzle-orm';
import { getCurrentUserId } from '../../../../lib/user';
import { revokeIntegrations } from '../../../../lib/revoke-integrations';
import { revokeIntegrations, isGoogleProvider } from '../../../../lib/revoke-integrations';

export async function DELETE(
_request: NextRequest,
Expand All @@ -25,7 +25,20 @@ export async function DELETE(
);
}

await revokeIntegrations([existing]);
// Disconnecting one Google source must not revoke the shared grant while another
// is still connected — that would silently break the one the user kept. Their own
// tokens are cleared below either way.
let shouldRevoke = true;
if (isGoogleProvider(existing.provider)) {
const connected = await db
.select({ provider: integrations.provider })
.from(integrations)
.where(and(eq(integrations.userId, userId), eq(integrations.status, 'connected')));
shouldRevoke = !connected.some(
(o) => isGoogleProvider(o.provider) && o.provider !== existing.provider,
);
}
if (shouldRevoke) await revokeIntegrations([existing]);

const [row] = await db
.update(integrations)
Expand Down
114 changes: 114 additions & 0 deletions apps/api/app/v1/integrations/github/repos/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, integrations, events } from '@baseline/db';
import { eq, and, desc } from 'drizzle-orm';
import { fetchRepositories } from '@baseline/integrations-github';
import { getCurrentUserId } from '../../../../../lib/user';

const PROVIDER = 'github';

interface Settings {
/** `owner/name` repos to sync. Absent means all of them. */
tracked_repos?: string[];
}

async function loadIntegration(userId: string) {
const [row] = await db
.select()
.from(integrations)
.where(and(eq(integrations.userId, userId), eq(integrations.provider, PROVIDER)));
return row;
}

/** Event counts per repo, so the page can show what each one has contributed. */
async function eventCountsByRepo(userId: string) {
const rows = await db
.select({ payload: events.payload })
.from(events)
.where(and(eq(events.userId, userId), eq(events.source, PROVIDER)))
.orderBy(desc(events.occurredAt));

const counts = new Map<string, number>();
for (const r of rows) {
const repo = (r.payload as Record<string, unknown> | null)?.repo as string | undefined;
if (repo) counts.set(repo, (counts.get(repo) ?? 0) + 1);
}
return counts;
}

export async function GET() {
const userId = await getCurrentUserId();
const integration = await loadIntegration(userId);

if (!integration || integration.status !== 'connected' || !integration.accessToken) {
return NextResponse.json(
{ error: 'GitHub not connected', code: 'NOT_CONNECTED' },
{ status: 404 },
);
}

const tracked = ((integration.settings ?? {}) as Settings).tracked_repos;

let repos;
try {
repos = await fetchRepositories(integration.accessToken);
} catch (err) {
const message = err instanceof Error ? err.message : '';
if (message === 'GITHUB_TOKEN_INVALID') {
return NextResponse.json(
{ error: 'Reconnect GitHub', code: 'TOKEN_INVALID' },
{ status: 409 },
);
}
throw err;
}

const counts = await eventCountsByRepo(userId);

return NextResponse.json({
repos: repos.map((r) => ({
name: r.nameWithOwner,
is_private: r.isPrivate,
is_fork: r.isFork,
is_archived: r.isArchived,
description: r.description,
language: r.primaryLanguage,
pushed_at: r.pushedAt,
// Absent settings means everything is tracked.
tracked: tracked ? tracked.includes(r.nameWithOwner) : true,
event_count: counts.get(r.nameWithOwner) ?? 0,
})),
last_synced_at: integration.lastSyncedAt,
});
}

export async function PUT(request: NextRequest) {
const userId = await getCurrentUserId();
const integration = await loadIntegration(userId);

if (!integration || integration.status !== 'connected') {
return NextResponse.json(
{ error: 'GitHub not connected', code: 'NOT_CONNECTED' },
{ status: 404 },
);
}

let body: { tracked_repos?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 });
}

const names = body.tracked_repos;
if (!Array.isArray(names) || names.some((v) => typeof v !== 'string')) {
return NextResponse.json(
{ error: 'tracked_repos must be an array of strings', code: 'INVALID_BODY' },
{ status: 400 },
);
}

const settings = { ...((integration.settings ?? {}) as Settings), tracked_repos: names as string[] };
await db.update(integrations).set({ settings }).where(eq(integrations.id, integration.id));

return NextResponse.json({ tracked_repos: names });
}
33 changes: 33 additions & 0 deletions apps/api/app/v1/integrations/google-books/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { buildAuthorizationUrl } from '@baseline/integrations-google-books';
import { getCurrentUserId } from '../../../../../lib/user';
import { resolvePublicOrigin } from '../../../../../lib/origin';

export async function GET() {
await getCurrentUserId();

const clientId = process.env.GOOGLE_CLIENT_ID;
if (!clientId) {
return NextResponse.json(
{ error: 'Google OAuth not configured', code: 'NOT_CONFIGURED' },
{ status: 500 },
);
}

const state = crypto.randomUUID();
const redirectUri = `${await resolvePublicOrigin()}/v1/integrations/google-books/callback`;

const cookieStore = await cookies();
// Distinct from the Calendar state cookie so connecting one cannot clobber a
// consent flow already in progress for the other.
cookieStore.set('google_books_oauth_state', state, {
httpOnly: true,
sameSite: 'lax',
maxAge: 600,
path: '/',
});

const url = buildAuthorizationUrl(clientId, redirectUri, state);
return NextResponse.redirect(url);
}
80 changes: 80 additions & 0 deletions apps/api/app/v1/integrations/google-books/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db, integrations } from '@baseline/db';
import { eq, and } from 'drizzle-orm';
import { exchangeCodeForToken, fetchGoogleUser } from '@baseline/integrations-google-books';
import { getCurrentUserId } from '../../../../../lib/user';
import { resolvePublicOrigin } from '../../../../../lib/origin';

const PROVIDER = 'google_books';

export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const webUrl = await resolvePublicOrigin();

const cookieStore = await cookies();
const storedState = cookieStore.get('google_books_oauth_state')?.value;
cookieStore.delete('google_books_oauth_state');

let userId: string;
try {
userId = await getCurrentUserId();
} catch {
return NextResponse.redirect(`${webUrl}/sign-in?error=not_signed_in`);
}

const gError = searchParams.get('error');
if (gError) {
return NextResponse.redirect(`${webUrl}/sources?error=${encodeURIComponent(gError)}`);
}

if (!code || !state || state !== storedState) {
return NextResponse.redirect(`${webUrl}/sources?error=invalid_state`);
}

const clientId = process.env.GOOGLE_CLIENT_ID!;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET!;
const redirectUri = `${webUrl}/v1/integrations/google-books/callback`;

try {
const tokenData = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri);
const googleUser = await fetchGoogleUser(tokenData.access_token);
const tokenExpiresAt = new Date(Date.now() + tokenData.expires_in * 1000);

const [existing] = await db
.select()
.from(integrations)
.where(and(eq(integrations.userId, userId), eq(integrations.provider, PROVIDER)));

if (existing) {
await db
.update(integrations)
.set({
status: 'connected',
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? existing.refreshToken,
tokenExpiresAt,
externalAccountId: googleUser.email,
connectedAt: new Date(),
})
.where(eq(integrations.id, existing.id));
} else {
await db.insert(integrations).values({
userId,
provider: PROVIDER,
status: 'connected',
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
tokenExpiresAt,
externalAccountId: googleUser.email,
});
}

return NextResponse.redirect(`${webUrl}/sources?success=google_books`);
} catch (err) {
console.error('Google Books OAuth callback error:', err);
return NextResponse.redirect(`${webUrl}/sources?error=oauth_failed`);
}
}
Loading
Loading