From 5ea7bb1083332bc02a239fa2f8367c7aeab7fda0 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 17 Aug 2026 21:57:25 +0800 Subject: [PATCH] feat(auth): add lastSeenAt field to user schema and tests --- .gitignore | 2 ++ .../src/schemas/auth-schema-contract.test.ts | 13 ++++++++++ server/apps/auth/src/auth.ts | 11 ++++++++ server/apps/auth/src/tests/auth.test.ts | 26 +++++++++++++++++++ server/packages/auth-shared/src/schema.ts | 8 ------ 5 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 server/apps/api/src/schemas/auth-schema-contract.test.ts diff --git a/.gitignore b/.gitignore index 638b94517..37b4728d6 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,5 @@ docs/ai/context/verifications/ # Generated from packages/i18n/glossary/terms.yaml at upload time. Never committed, so it # cannot drift from its source. packages/i18n/glossary/glossary.tbx + +/airi-docs/ diff --git a/server/apps/api/src/schemas/auth-schema-contract.test.ts b/server/apps/api/src/schemas/auth-schema-contract.test.ts new file mode 100644 index 000000000..fbe403735 --- /dev/null +++ b/server/apps/api/src/schemas/auth-schema-contract.test.ts @@ -0,0 +1,13 @@ +import { user } from '@proj-airi/auth-shared' +import { getTableColumns } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' + +describe('better Auth schema contract', () => { + it('keeps the application-owned last_seen_at user column', () => { + const columns = getTableColumns(user) + + expect(columns.lastSeenAt).toBeDefined() + expect(columns.lastSeenAt.name).toBe('last_seen_at') + expect(columns.lastSeenAt.notNull).toBe(false) + }) +}) diff --git a/server/apps/auth/src/auth.ts b/server/apps/auth/src/auth.ts index 7cd9f4230..3c8506b99 100644 --- a/server/apps/auth/src/auth.ts +++ b/server/apps/auth/src/auth.ts @@ -542,6 +542,17 @@ export function createAuth( }, user: { + // Keep this server-managed activity field in Better Auth's declared + // schema so `better-auth generate` preserves it in auth-shared. + // Session creation updates it below; clients must never supply it. + additionalFields: { + lastSeenAt: { + type: 'date', + required: false, + input: false, + returned: true, + }, + }, changeEmail: { enabled: true, // NOTICE: diff --git a/server/apps/auth/src/tests/auth.test.ts b/server/apps/auth/src/tests/auth.test.ts index bbaf8bf6e..fd04303c7 100644 --- a/server/apps/auth/src/tests/auth.test.ts +++ b/server/apps/auth/src/tests/auth.test.ts @@ -3,6 +3,7 @@ import type { AuthEnv } from '../env' import { generateKeyPairSync } from 'node:crypto' +import { getAuthTables } from 'better-auth/db' import { decodeJwt, decodeProtectedHeader, importSPKI, jwtVerify } from 'jose' import { describe, expect, it, vi } from 'vitest' @@ -59,6 +60,31 @@ describe('createAuth', () => { expect(auth.options.account?.accountLinking?.allowDifferentEmails).toBe(true) }) + it('registers lastSeenAt as a server-managed Better Auth user field', () => { + const auth = createAuth({} as unknown as AuthDatabase, { + PUBLIC_URL: 'http://localhost:3000', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', + ADDITIONAL_TRUSTED_ORIGINS: [], + } as unknown as AuthEnv) + + expect(auth.options.user?.additionalFields?.lastSeenAt).toMatchObject({ + type: 'date', + required: false, + input: false, + returned: true, + }) + expect(getAuthTables(auth.options).user.fields.lastSeenAt).toMatchObject({ + type: 'date', + required: false, + input: false, + returned: true, + }) + }) + it('asks social providers to show the account picker during OAuth authorization', () => { const auth = createAuth({} as unknown as AuthDatabase, { PUBLIC_URL: 'http://localhost:3000', diff --git a/server/packages/auth-shared/src/schema.ts b/server/packages/auth-shared/src/schema.ts index fcdce5b88..fec2db651 100644 --- a/server/packages/auth-shared/src/schema.ts +++ b/server/packages/auth-shared/src/schema.ts @@ -13,14 +13,6 @@ export const user = pgTable('user', { banned: boolean('banned').default(false), banReason: text('ban_reason'), banExpires: timestamp('ban_expires'), - // NOTICE: - // Touched in `databaseHooks.session.create.after` (see auth.ts) which - // fires on sign-in AND on every OIDC access-token refresh (~hourly), so - // this is effectively "last activity" for any user with a live client. - // Better Auth has `session.updatedAt` but that's per-session-row; we - // want one stable per-user timestamp for DAU-style queries without - // joining/aggregating session rows. Nullable so existing rows backfill - // lazily on next login instead of needing a migration-time seed. lastSeenAt: timestamp('last_seen_at'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at')