Files
Genarrative/server-node/src/repositories/userSessionRepository.ts
2026-04-10 15:37:02 +08:00

215 lines
5.7 KiB
TypeScript

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<UserSessionRow>(
`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<UserSessionRow>(
`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<UserSessionRow>(
`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<UserSessionRow>(
`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<UserSessionRow>(
`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<UserSessionRow>(
`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<UserSessionRow>(
`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]);
}
}