Add transactions to auth procedures and extract DB models

- Wrap multiple DB operations in transactions for atomicity:
  - login-if-completed: device upsert + session + login_request deletion
  - forgot-password: delete old tokens + insert new token
  - signup: session + email_verification creation

- Extract reusable DB model operations to packages/db/src/models/:
  - sessions.ts: insertSession()
  - user-devices.ts: upsertUserDevice(), isDeviceTrusted()

- Update session.ts to use new model functions from @reviq/db
- Fix type narrowing in admin.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 15:52:05 +08:00
parent 5a2e0297e5
commit b085a315be
9 changed files with 241 additions and 124 deletions

View File

@@ -33,3 +33,7 @@ export {
parseToken,
TOKEN_PREFIX,
} from "./helpers/token.js";
/**
* Export model operations
*/
export * from "./models/index.js";

View File

@@ -0,0 +1,7 @@
/**
* Database model operations
* Reusable database functions organized by table
*/
export * from "./sessions.js";
export * from "./user-devices.js";

View File

@@ -0,0 +1,53 @@
/**
* Database operations for sessions table
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
import type { DeviceGeoInfo } from "./user-devices.js";
/** Options for inserting a session */
export interface InsertSessionOptions {
userId: number;
deviceId: number | null;
tokenHash: string;
trustedMode: boolean;
geo: DeviceGeoInfo;
userAgent: string;
expiresAt: Date;
}
/** Result of session insertion */
export interface InsertSessionResult {
sessionId: number;
}
/**
* Insert a new session record
* Note: Token generation and hashing should be done by the caller
*/
export async function insertSession(
db: Kysely<Database> | Transaction<Database>,
options: InsertSessionOptions,
): Promise<InsertSessionResult> {
const result = await db
.insertInto("sessions")
.values({
user_id: options.userId,
device_id: options.deviceId,
token_hash: options.tokenHash,
trusted_mode: options.trustedMode,
ip_address: options.geo.ip,
city: options.geo.city,
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: options.expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return {
sessionId: Number(result.id),
};
}

View File

@@ -0,0 +1,71 @@
/**
* Database operations for user_devices table
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
/** Geo information for device tracking */
export interface DeviceGeoInfo {
ip: string | null;
city: string | null;
region: string | null;
country: string | null;
}
/**
* Upsert a user device record
* Creates new device if not exists, updates last_used_at if exists
* @returns The device ID
*/
export async function upsertUserDevice(
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
geo: DeviceGeoInfo,
userAgent: string,
): Promise<number> {
const result = await db
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: deviceFingerprint,
user_agent: userAgent,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
})
.onConflict((oc) =>
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
user_agent: userAgent,
last_used_at: new Date(),
}),
)
.returning(["id"])
.executeTakeFirstOrThrow();
return Number(result.id);
}
/**
* Check if a device is trusted for a user
*/
export async function isDeviceTrusted(
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
): Promise<boolean> {
const device = await db
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
}