import crypto from 'node:crypto'; import type { QueryResultRow } from 'pg'; import type { AppDatabase } from '../db.js'; export type UserSessionRecord = { id: string; userId: string; refreshTokenHash: string; clientType: string; userAgent: string | null; ip: string | null; expiresAt: string; revokedAt: string | null; createdAt: string; updatedAt: string; lastSeenAt: string; }; type UserSessionRow = QueryResultRow & { id: string; user_id: string; refresh_token_hash: string; client_type: string; user_agent: string | null; ip: string | null; expires_at: string; revoked_at: string | null; created_at: string; updated_at: string; last_seen_at: string; }; function toUserSessionRecord( row: UserSessionRow | undefined, ): UserSessionRecord | null { if (!row) { return null; } return { id: row.id, userId: row.user_id, refreshTokenHash: row.refresh_token_hash, clientType: row.client_type, userAgent: row.user_agent, ip: row.ip, expiresAt: row.expires_at, revokedAt: row.revoked_at, createdAt: row.created_at, updatedAt: row.updated_at, lastSeenAt: row.last_seen_at, }; } export type CreateUserSessionInput = { userId: string; refreshTokenHash: string; clientType: string; userAgent: string | null; ip: string | null; expiresAt: string; }; export class UserSessionRepository { constructor(private readonly db: AppDatabase) {} async create(input: CreateUserSessionInput) { const now = new Date().toISOString(); const sessionId = `usess_${crypto.randomBytes(16).toString('hex')}`; const result = await this.db.query( `INSERT INTO user_sessions ( id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10) RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, [ sessionId, input.userId, input.refreshTokenHash, input.clientType, input.userAgent, input.ip, input.expiresAt, now, now, now, ], ); return toUserSessionRecord(result.rows[0]); } async findActiveByRefreshTokenHash(refreshTokenHash: string) { const result = await this.db.query( `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at FROM user_sessions WHERE refresh_token_hash = $1 LIMIT 1`, [refreshTokenHash], ); return toUserSessionRecord(result.rows[0]); } async rotate( sessionId: string, input: { refreshTokenHash: string; expiresAt: string; lastSeenAt: string; }, ) { const result = await this.db.query( `UPDATE user_sessions SET refresh_token_hash = $1, expires_at = $2, last_seen_at = $3, updated_at = $4 WHERE id = $5 RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, [ input.refreshTokenHash, input.expiresAt, input.lastSeenAt, new Date().toISOString(), sessionId, ], ); return toUserSessionRecord(result.rows[0]); } async revoke(sessionId: string) { const now = new Date().toISOString(); const result = await this.db.query( `UPDATE user_sessions SET revoked_at = $1, updated_at = $2 WHERE id = $3 RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, [now, now, sessionId], ); return toUserSessionRecord(result.rows[0]); } async findById(sessionId: string) { const result = await this.db.query( `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at FROM user_sessions WHERE id = $1 LIMIT 1`, [sessionId], ); return toUserSessionRecord(result.rows[0]); } async listActiveByUserId(userId: string) { const result = await this.db.query( `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at FROM user_sessions WHERE user_id = $1 AND revoked_at IS NULL ORDER BY last_seen_at DESC, created_at DESC`, [userId], ); return result.rows .map((row) => toUserSessionRecord(row)) .filter((row): row is UserSessionRecord => Boolean(row)); } async revokeAllByUserId(userId: string) { const now = new Date().toISOString(); await this.db.query( `UPDATE user_sessions SET revoked_at = $1, updated_at = $2 WHERE user_id = $3 AND revoked_at IS NULL`, [now, now, userId], ); } async revokeByUserIdAndSessionId(userId: string, sessionId: string) { const now = new Date().toISOString(); const result = await this.db.query( `UPDATE user_sessions SET revoked_at = $1, updated_at = $2 WHERE user_id = $3 AND id = $4 AND revoked_at IS NULL RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, [now, now, userId, sessionId], ); return toUserSessionRecord(result.rows[0]); } }