added migration so that we can do refresh token

This commit is contained in:
Jun-te Kim 2026-01-18 19:19:42 +00:00
parent 18312c939a
commit 8bae792d16
5 changed files with 110 additions and 14 deletions

View file

@ -0,0 +1,6 @@
ALTER TABLE public.xero_connections
ADD COLUMN access_token TEXT NOT NULL,
ADD COLUMN refresh_token TEXT NOT NULL,
ADD COLUMN expires_at TIMESTAMPTZ NOT NULL;
CREATE UNIQUE INDEX xero_connections_user_id_idx
ON public.xero_connections(user_id);

View file

@ -1,4 +1,4 @@
h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c=
h1:LpRuw3gJ5nRNyvvsHmpXYiiiMfrzaM73K9Rozybl9gg=
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
@ -7,3 +7,4 @@ h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c=
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w=
20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:ybrF538zPFYK2mjgatJmrbDZu5MBP5T+aY5no9wkyw0=

View file

@ -7,6 +7,7 @@ import { eq } from "drizzle-orm";
type XeroTokenResponse = {
access_token: string;
refresh_token: string;
expires_in: number; // seconds
};
type XeroTenant = {
@ -17,7 +18,6 @@ export async function GET(req: NextRequest) {
const cookieStore = await cookies();
const session = cookieStore.get("session");
// Must be logged in
if (!session) {
return NextResponse.redirect(
new URL("/login", process.env.APP_URL)
@ -36,7 +36,7 @@ export async function GET(req: NextRequest) {
);
}
// Exchange code for token
// 1 Exchange code for tokens
const tokenRes = await fetch("https://identity.xero.com/connect/token", {
method: "POST",
headers: {
@ -64,15 +64,12 @@ export async function GET(req: NextRequest) {
const tokenData = (await tokenRes.json()) as XeroTokenResponse;
// Fetch connected tenants (organisations)
const tenantRes = await fetch(
"https://api.xero.com/connections",
{
// 2⃣ Fetch tenant
const tenantRes = await fetch("https://api.xero.com/connections", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
}
);
});
const tenants = (await tenantRes.json()) as XeroTenant[];
const tenantId = tenants[0]?.tenantId;
@ -84,16 +81,28 @@ export async function GET(req: NextRequest) {
);
}
// Save user ↔ tenant (minimal MVP)
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000);
// 3⃣ Persist EVERYTHING (this is the fix)
await db
.insert(xeroConnections)
.values({
userId,
tenantId,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: xeroConnections.userId,
set: { tenantId },
set: {
tenantId,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt,
updatedAt: new Date(),
},
});
return NextResponse.redirect(

View file

@ -3,11 +3,25 @@ import { users } from "./users";
export const xeroConnections = pgTable("xero_connections", {
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tenantId: text("tenant_id").notNull(),
// 🔐 OAuth tokens
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token").notNull(),
// When access_token expires
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});

View file

@ -0,0 +1,66 @@
import "server-only";
import { db } from "@/lib/db";
import { xeroConnections } from "@/lib/schema/xeroConnections";
import { eq } from "drizzle-orm";
/**
* Returns a valid Xero access token for the given user.
* Refreshes and persists tokens automatically if expired.
*/
export async function getValidXeroAccessToken(userId: string): Promise<string> {
const conn = await db.query.xeroConnections.findFirst({
where: (t, { eq }) => eq(t.userId, userId),
});
if (!conn) {
throw new Error("No Xero connection");
}
const now = Date.now();
// Access token still valid (60s safety buffer)
if (now < conn.expiresAt.getTime() - 60_000) {
return conn.accessToken;
}
// Refresh token
const res = await fetch("https://identity.xero.com/connect/token", {
method: "POST",
headers: {
Authorization:
"Basic " +
Buffer.from(
`${process.env.XERO_CLIENT_ID}:${process.env.XERO_CLIENT_SECRET}`
).toString("base64"),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: conn.refreshToken,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to refresh Xero token: ${text}`);
}
const tokens: {
access_token: string;
refresh_token: string;
expires_in: number;
} = await res.json();
await db
.update(xeroConnections)
.set({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token, // 🔥 must overwrite
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
updatedAt: new Date(),
})
.where(eq(xeroConnections.id, conn.id));
return tokens.access_token;
}