1
This commit is contained in:
105
server-node/src/repositories/authAuditLogRepository.ts
Normal file
105
server-node/src/repositories/authAuditLogRepository.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type { AuthAuditLogEventType } from '../../../packages/shared/src/contracts/auth.js';
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
export type AuthAuditLogRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
eventType: AuthAuditLogEventType;
|
||||
detail: string;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
metaJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AuthAuditLogRow = QueryResultRow & {
|
||||
id: string;
|
||||
user_id: string;
|
||||
event_type: AuthAuditLogEventType;
|
||||
detail: string;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
meta_json: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
function toAuthAuditLogRecord(
|
||||
row: AuthAuditLogRow | undefined,
|
||||
): AuthAuditLogRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
eventType: row.event_type,
|
||||
detail: row.detail,
|
||||
ip: row.ip,
|
||||
userAgent: row.user_agent,
|
||||
metaJson: row.meta_json,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthAuditLogRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
async create(input: {
|
||||
userId: string;
|
||||
eventType: AuthAuditLogEventType;
|
||||
detail: string;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
metaJson?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const id = `audit_${crypto.randomBytes(16).toString('hex')}`;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
const result = await this.db.query<AuthAuditLogRow>(
|
||||
`INSERT INTO auth_audit_logs (
|
||||
id,
|
||||
user_id,
|
||||
event_type,
|
||||
detail,
|
||||
ip,
|
||||
user_agent,
|
||||
meta_json,
|
||||
created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, event_type, detail, ip, user_agent, meta_json, created_at`,
|
||||
[
|
||||
id,
|
||||
input.userId,
|
||||
input.eventType,
|
||||
input.detail,
|
||||
input.ip,
|
||||
input.userAgent,
|
||||
input.metaJson ?? null,
|
||||
createdAt,
|
||||
],
|
||||
);
|
||||
|
||||
return toAuthAuditLogRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async listRecentByUserId(userId: string, limit = 20) {
|
||||
const result = await this.db.query<AuthAuditLogRow>(
|
||||
`SELECT id, user_id, event_type, detail, ip, user_agent, meta_json, created_at
|
||||
FROM auth_audit_logs
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, limit],
|
||||
);
|
||||
|
||||
return result.rows
|
||||
.map((row) => toAuthAuditLogRecord(row))
|
||||
.filter((row): row is AuthAuditLogRecord => Boolean(row));
|
||||
}
|
||||
}
|
||||
156
server-node/src/repositories/authIdentityRepository.ts
Normal file
156
server-node/src/repositories/authIdentityRepository.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
export type AuthIdentityProvider = 'wechat';
|
||||
|
||||
export type AuthIdentityRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
provider: AuthIdentityProvider;
|
||||
providerUid: string;
|
||||
providerUnionId: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
isVerified: boolean;
|
||||
metaJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AuthIdentityRow = QueryResultRow & {
|
||||
id: string;
|
||||
user_id: string;
|
||||
provider: AuthIdentityProvider;
|
||||
provider_uid: string;
|
||||
provider_unionid: string | null;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
is_verified: boolean;
|
||||
meta_json: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function toAuthIdentityRecord(
|
||||
row: AuthIdentityRow | undefined,
|
||||
): AuthIdentityRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
provider: row.provider,
|
||||
providerUid: row.provider_uid,
|
||||
providerUnionId: row.provider_unionid,
|
||||
displayName: row.display_name,
|
||||
avatarUrl: row.avatar_url,
|
||||
isVerified: row.is_verified,
|
||||
metaJson: row.meta_json,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export type CreateWechatIdentityInput = {
|
||||
userId: string;
|
||||
providerUid: string;
|
||||
providerUnionId: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
metaJson?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export class AuthIdentityRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
async findWechatIdentityByProfile(params: {
|
||||
providerUid: string;
|
||||
providerUnionId: string | null;
|
||||
}) {
|
||||
const result = params.providerUnionId
|
||||
? await this.db.query<AuthIdentityRow>(
|
||||
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
|
||||
FROM auth_identities
|
||||
WHERE provider = 'wechat'
|
||||
AND (provider_unionid = $1 OR provider_uid = $2)
|
||||
ORDER BY
|
||||
CASE WHEN provider_unionid = $1 THEN 0 ELSE 1 END
|
||||
LIMIT 1`,
|
||||
[params.providerUnionId, params.providerUid],
|
||||
)
|
||||
: await this.db.query<AuthIdentityRow>(
|
||||
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
|
||||
FROM auth_identities
|
||||
WHERE provider = 'wechat'
|
||||
AND provider_uid = $1
|
||||
LIMIT 1`,
|
||||
[params.providerUid],
|
||||
);
|
||||
|
||||
return toAuthIdentityRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async listByUserId(userId: string) {
|
||||
const result = await this.db.query<AuthIdentityRow>(
|
||||
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
|
||||
FROM auth_identities
|
||||
WHERE user_id = $1
|
||||
ORDER BY provider, created_at`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows
|
||||
.map((row) => toAuthIdentityRecord(row))
|
||||
.filter((row): row is AuthIdentityRecord => Boolean(row));
|
||||
}
|
||||
|
||||
async createWechatIdentity(input: CreateWechatIdentityInput) {
|
||||
const now = new Date().toISOString();
|
||||
const identityId = `authi_${crypto.randomBytes(16).toString('hex')}`;
|
||||
const result = await this.db.query<AuthIdentityRow>(
|
||||
`INSERT INTO auth_identities (
|
||||
id,
|
||||
user_id,
|
||||
provider,
|
||||
provider_uid,
|
||||
provider_unionid,
|
||||
display_name,
|
||||
avatar_url,
|
||||
is_verified,
|
||||
meta_json,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, 'wechat', $3, $4, $5, $6, TRUE, $7, $8, $9)
|
||||
RETURNING id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at`,
|
||||
[
|
||||
identityId,
|
||||
input.userId,
|
||||
input.providerUid,
|
||||
input.providerUnionId,
|
||||
input.displayName,
|
||||
input.avatarUrl,
|
||||
input.metaJson ?? null,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
return toAuthIdentityRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async moveWechatIdentitiesToUser(sourceUserId: string, targetUserId: string) {
|
||||
await this.db.query(
|
||||
`UPDATE auth_identities
|
||||
SET user_id = $1, updated_at = $2
|
||||
WHERE user_id = $3
|
||||
AND provider = 'wechat'`,
|
||||
[targetUserId, new Date().toISOString(), sourceUserId],
|
||||
);
|
||||
}
|
||||
}
|
||||
128
server-node/src/repositories/authRiskBlockRepository.ts
Normal file
128
server-node/src/repositories/authRiskBlockRepository.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
export type AuthRiskBlockScopeType = 'phone' | 'ip';
|
||||
|
||||
export type AuthRiskBlockRecord = {
|
||||
id: string;
|
||||
scopeType: AuthRiskBlockScopeType;
|
||||
scopeKey: string;
|
||||
reason: string;
|
||||
expiresAt: string;
|
||||
liftedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AuthRiskBlockRow = QueryResultRow & {
|
||||
id: string;
|
||||
scope_type: AuthRiskBlockScopeType;
|
||||
scope_key: string;
|
||||
reason: string;
|
||||
expires_at: string;
|
||||
lifted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function toAuthRiskBlockRecord(
|
||||
row: AuthRiskBlockRow | undefined,
|
||||
): AuthRiskBlockRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
scopeType: row.scope_type,
|
||||
scopeKey: row.scope_key,
|
||||
reason: row.reason,
|
||||
expiresAt: row.expires_at,
|
||||
liftedAt: row.lifted_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthRiskBlockRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
async findActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) {
|
||||
const result = await this.db.query<AuthRiskBlockRow>(
|
||||
`SELECT id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at
|
||||
FROM auth_risk_blocks
|
||||
WHERE scope_type = $1
|
||||
AND scope_key = $2
|
||||
AND lifted_at IS NULL
|
||||
AND expires_at > $3
|
||||
ORDER BY expires_at DESC
|
||||
LIMIT 1`,
|
||||
[scopeType, scopeKey, new Date().toISOString()],
|
||||
);
|
||||
|
||||
return toAuthRiskBlockRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async createOrRefresh(input: {
|
||||
scopeType: AuthRiskBlockScopeType;
|
||||
scopeKey: string;
|
||||
reason: string;
|
||||
expiresAt: string;
|
||||
}) {
|
||||
const existing = await this.findActive(input.scopeType, input.scopeKey);
|
||||
if (existing) {
|
||||
const result = await this.db.query<AuthRiskBlockRow>(
|
||||
`UPDATE auth_risk_blocks
|
||||
SET reason = $1,
|
||||
expires_at = $2,
|
||||
updated_at = $3
|
||||
WHERE id = $4
|
||||
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
|
||||
[input.reason, input.expiresAt, new Date().toISOString(), existing.id],
|
||||
);
|
||||
return toAuthRiskBlockRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
const id = `risk_${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const result = await this.db.query<AuthRiskBlockRow>(
|
||||
`INSERT INTO auth_risk_blocks (
|
||||
id,
|
||||
scope_type,
|
||||
scope_key,
|
||||
reason,
|
||||
expires_at,
|
||||
lifted_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7)
|
||||
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
|
||||
[id, input.scopeType, input.scopeKey, input.reason, input.expiresAt, now, now],
|
||||
);
|
||||
|
||||
return toAuthRiskBlockRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async liftActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) {
|
||||
const now = new Date().toISOString();
|
||||
const result = await this.db.query<AuthRiskBlockRow>(
|
||||
`UPDATE auth_risk_blocks
|
||||
SET lifted_at = $1,
|
||||
updated_at = $2
|
||||
WHERE scope_type = $3
|
||||
AND scope_key = $4
|
||||
AND lifted_at IS NULL
|
||||
AND expires_at > $5
|
||||
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
|
||||
[now, now, scopeType, scopeKey, now],
|
||||
);
|
||||
|
||||
return result.rows
|
||||
.map((row) => toAuthRiskBlockRecord(row))
|
||||
.filter((row): row is AuthRiskBlockRecord => Boolean(row));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import {
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
SAVE_SNAPSHOT_VERSION,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type {
|
||||
CustomWorldProfileRecord,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
const SAVE_SNAPSHOT_VERSION = 2;
|
||||
const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
const MAX_CUSTOM_WORLD_PROFILES = 12;
|
||||
|
||||
export type SavedSnapshot = {
|
||||
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
|
||||
|
||||
type SnapshotRow = QueryResultRow & {
|
||||
version: number;
|
||||
savedAt: string;
|
||||
gameState: unknown;
|
||||
@@ -12,37 +23,53 @@ export type SavedSnapshot = {
|
||||
currentStory: unknown;
|
||||
};
|
||||
|
||||
export type RuntimeSettings = {
|
||||
type SettingsRow = QueryResultRow & {
|
||||
musicVolume: number;
|
||||
};
|
||||
|
||||
function parseJson<T>(value: string): T {
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
type ProfileRow = QueryResultRow & {
|
||||
payload: CustomWorldProfileRecord;
|
||||
};
|
||||
|
||||
function toJson(value: unknown) {
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
export type RuntimeRepositoryPort = {
|
||||
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
||||
putSnapshot(
|
||||
userId: string,
|
||||
payload: Omit<SavedSnapshot, 'version'>,
|
||||
): Promise<SavedSnapshot>;
|
||||
deleteSnapshot(userId: string): Promise<void>;
|
||||
getSettings(userId: string): Promise<RuntimeSettings>;
|
||||
putSettings(
|
||||
userId: string,
|
||||
settings: RuntimeSettings,
|
||||
): Promise<RuntimeSettings>;
|
||||
listCustomWorldProfiles(userId: string): Promise<CustomWorldProfileRecord[]>;
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
): Promise<CustomWorldProfileRecord[]>;
|
||||
deleteCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldProfileRecord[]>;
|
||||
};
|
||||
|
||||
export class RuntimeRepository {
|
||||
export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
getSnapshot(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
|
||||
FROM save_snapshots
|
||||
WHERE user_id = ?`,
|
||||
)
|
||||
.get(userId) as
|
||||
| {
|
||||
version: number;
|
||||
saved_at: string;
|
||||
game_state_json: string;
|
||||
bottom_tab: string;
|
||||
current_story_json: string;
|
||||
}
|
||||
| undefined;
|
||||
async getSnapshot(userId: string) {
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`SELECT version,
|
||||
saved_at AS "savedAt",
|
||||
game_state_json AS "gameState",
|
||||
bottom_tab AS "bottomTab",
|
||||
current_story_json AS "currentStory"
|
||||
FROM save_snapshots
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
@@ -50,14 +77,14 @@ export class RuntimeRepository {
|
||||
|
||||
return {
|
||||
version: row.version,
|
||||
savedAt: row.saved_at,
|
||||
gameState: parseJson(row.game_state_json),
|
||||
bottomTab: row.bottom_tab,
|
||||
currentStory: parseJson(row.current_story_json),
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
}
|
||||
|
||||
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
|
||||
async putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
|
||||
const snapshot = {
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
savedAt: payload.savedAt,
|
||||
@@ -67,115 +94,126 @@ export class RuntimeRepository {
|
||||
} satisfies SavedSnapshot;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO save_snapshots (
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`INSERT INTO save_snapshots (
|
||||
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
saved_at = excluded.saved_at,
|
||||
bottom_tab = excluded.bottom_tab,
|
||||
game_state_json = excluded.game_state_json,
|
||||
current_story_json = excluded.current_story_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
saved_at = EXCLUDED.saved_at,
|
||||
bottom_tab = EXCLUDED.bottom_tab,
|
||||
game_state_json = EXCLUDED.game_state_json,
|
||||
current_story_json = EXCLUDED.current_story_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING version,
|
||||
saved_at AS "savedAt",
|
||||
game_state_json AS "gameState",
|
||||
bottom_tab AS "bottomTab",
|
||||
current_story_json AS "currentStory"`,
|
||||
[
|
||||
userId,
|
||||
snapshot.version,
|
||||
snapshot.savedAt,
|
||||
snapshot.bottomTab,
|
||||
toJson(snapshot.gameState),
|
||||
toJson(snapshot.currentStory),
|
||||
snapshot.gameState,
|
||||
snapshot.currentStory,
|
||||
now,
|
||||
);
|
||||
],
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
}
|
||||
|
||||
deleteSnapshot(userId: string) {
|
||||
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
|
||||
async deleteSnapshot(userId: string) {
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]);
|
||||
}
|
||||
|
||||
getSettings(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT music_volume
|
||||
FROM runtime_settings
|
||||
WHERE user_id = ?`,
|
||||
)
|
||||
.get(userId) as { music_volume: number } | undefined;
|
||||
async getSettings(userId: string) {
|
||||
const result = await this.db.query<SettingsRow>(
|
||||
`SELECT music_volume AS "musicVolume"
|
||||
FROM runtime_settings
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
musicVolume:
|
||||
typeof row?.music_volume === 'number'
|
||||
? row.music_volume
|
||||
typeof row?.musicVolume === 'number'
|
||||
? row.musicVolume
|
||||
: DEFAULT_MUSIC_VOLUME,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
putSettings(userId: string, settings: RuntimeSettings) {
|
||||
async putSettings(userId: string, settings: RuntimeSettings) {
|
||||
const nextSettings = {
|
||||
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
|
||||
} satisfies RuntimeSettings;
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
music_volume = excluded.music_volume,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(userId, nextSettings.musicVolume, new Date().toISOString());
|
||||
const result = await this.db.query<SettingsRow>(
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
music_volume = EXCLUDED.music_volume,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING music_volume AS "musicVolume"`,
|
||||
[userId, nextSettings.musicVolume, new Date().toISOString()],
|
||||
);
|
||||
|
||||
return nextSettings;
|
||||
return {
|
||||
musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
listCustomWorldProfiles(userId: string) {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT payload_json
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
|
||||
async listCustomWorldProfiles(userId: string) {
|
||||
const result = await this.db.query<ProfileRow>(
|
||||
`SELECT payload_json AS payload
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, MAX_CUSTOM_WORLD_PROFILES],
|
||||
);
|
||||
|
||||
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
|
||||
return result.rows.map((row: ProfileRow) => row.payload);
|
||||
}
|
||||
|
||||
upsertCustomWorldProfile(
|
||||
async upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
profile: CustomWorldProfileRecord,
|
||||
) {
|
||||
const payload = {
|
||||
...profile,
|
||||
id: profileId,
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(userId, profileId, JSON.stringify(payload), new Date().toISOString());
|
||||
await this.db.query(
|
||||
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, profile_id) DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[userId, profileId, payload, new Date().toISOString()],
|
||||
);
|
||||
|
||||
return this.listCustomWorldProfiles(userId);
|
||||
}
|
||||
|
||||
deleteCustomWorldProfile(userId: string, profileId: string) {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM custom_world_profiles
|
||||
WHERE user_id = ? AND profile_id = ?`,
|
||||
)
|
||||
.run(userId, profileId);
|
||||
async deleteCustomWorldProfile(userId: string, profileId: string) {
|
||||
await this.db.query(
|
||||
`DELETE FROM custom_world_profiles
|
||||
WHERE user_id = $1 AND profile_id = $2`,
|
||||
[userId, profileId],
|
||||
);
|
||||
|
||||
return this.listCustomWorldProfiles(userId);
|
||||
}
|
||||
|
||||
102
server-node/src/repositories/smsAuthEventRepository.ts
Normal file
102
server-node/src/repositories/smsAuthEventRepository.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
export type SmsAuthScene = 'login' | 'bind_phone' | 'change_phone';
|
||||
export type SmsAuthAction = 'send_code' | 'verify_code';
|
||||
|
||||
type SmsAuthEventRow = QueryResultRow & {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export class SmsAuthEventRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
async create(input: {
|
||||
phoneNumber: string;
|
||||
scene: SmsAuthScene;
|
||||
action: SmsAuthAction;
|
||||
success: boolean;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
}) {
|
||||
const id = `smsev_${crypto.randomBytes(16).toString('hex')}`;
|
||||
await this.db.query(
|
||||
`INSERT INTO sms_auth_events (
|
||||
id,
|
||||
phone_number,
|
||||
scene,
|
||||
action,
|
||||
success,
|
||||
ip,
|
||||
user_agent,
|
||||
created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
id,
|
||||
input.phoneNumber,
|
||||
input.scene,
|
||||
input.action,
|
||||
input.success,
|
||||
input.ip,
|
||||
input.userAgent,
|
||||
new Date().toISOString(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async countSinceByPhone(params: {
|
||||
phoneNumber: string;
|
||||
action: SmsAuthAction;
|
||||
success?: boolean;
|
||||
since: string;
|
||||
}) {
|
||||
const result = await this.db.query<SmsAuthEventRow>(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM sms_auth_events
|
||||
WHERE phone_number = $1
|
||||
AND action = $2
|
||||
AND ($3::boolean IS NULL OR success = $3)
|
||||
AND created_at >= $4`,
|
||||
[
|
||||
params.phoneNumber,
|
||||
params.action,
|
||||
params.success ?? null,
|
||||
params.since,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0]?.total ?? 0;
|
||||
}
|
||||
|
||||
async countSinceByIp(params: {
|
||||
ip: string | null;
|
||||
action: SmsAuthAction;
|
||||
success?: boolean;
|
||||
since: string;
|
||||
}) {
|
||||
if (!params.ip) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.db.query<SmsAuthEventRow>(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM sms_auth_events
|
||||
WHERE ip = $1
|
||||
AND action = $2
|
||||
AND ($3::boolean IS NULL OR success = $3)
|
||||
AND created_at >= $4`,
|
||||
[
|
||||
params.ip,
|
||||
params.action,
|
||||
params.success ?? null,
|
||||
params.since,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0]?.total ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,33 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
export type UserRecord = {
|
||||
id: string;
|
||||
username: string;
|
||||
username: string | null;
|
||||
passwordHash: string;
|
||||
tokenVersion: number;
|
||||
displayName: string;
|
||||
loginProvider: 'password' | 'phone' | 'wechat';
|
||||
accountStatus: 'active' | 'pending_bind_phone' | 'disabled';
|
||||
phoneNumber: string | null;
|
||||
phoneVerifiedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
type UserRow = QueryResultRow & {
|
||||
id: string;
|
||||
username: string;
|
||||
username: string | null;
|
||||
password_hash: string;
|
||||
token_version: number;
|
||||
display_name: string;
|
||||
login_provider: 'password' | 'phone' | 'wechat';
|
||||
account_status: 'active' | 'pending_bind_phone' | 'disabled';
|
||||
phone_number: string | null;
|
||||
phone_verified_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -30,59 +42,249 @@ function toUserRecord(row: UserRow | undefined): UserRecord | null {
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
tokenVersion: row.token_version,
|
||||
displayName: row.display_name,
|
||||
loginProvider: row.login_provider,
|
||||
accountStatus: row.account_status,
|
||||
phoneNumber: row.phone_number,
|
||||
phoneVerifiedAt: row.phone_verified_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
export type CreatePhoneUserInput = {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
phoneNumber: string;
|
||||
phoneVerifiedAt: string;
|
||||
};
|
||||
|
||||
export type CreateWechatPendingUserInput = {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type UserRepositoryPort = {
|
||||
findByUsername(username: string): Promise<UserRecord | null>;
|
||||
findByPhoneNumber(phoneNumber: string): Promise<UserRecord | null>;
|
||||
findById(userId: string): Promise<UserRecord | null>;
|
||||
create(username: string, passwordHash: string): Promise<UserRecord | null>;
|
||||
createPhoneUser(input: CreatePhoneUserInput): Promise<UserRecord | null>;
|
||||
createWechatPendingUser(
|
||||
input: CreateWechatPendingUserInput,
|
||||
): Promise<UserRecord | null>;
|
||||
activatePendingWechatUser(
|
||||
userId: string,
|
||||
params: {
|
||||
displayName: string;
|
||||
phoneNumber: string;
|
||||
phoneVerifiedAt: string;
|
||||
},
|
||||
): Promise<UserRecord | null>;
|
||||
updatePhoneInfo(
|
||||
userId: string,
|
||||
params: {
|
||||
phoneNumber: string;
|
||||
phoneVerifiedAt: string;
|
||||
displayName?: string;
|
||||
},
|
||||
): Promise<UserRecord | null>;
|
||||
deleteUser(userId: string): Promise<void>;
|
||||
incrementTokenVersion(userId: string): Promise<UserRecord | null>;
|
||||
};
|
||||
|
||||
export class UserRepository implements UserRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
findByUsername(username: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, username, password_hash, token_version, created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = ?`,
|
||||
)
|
||||
.get(username) as UserRow | undefined;
|
||||
return toUserRecord(row);
|
||||
async findByUsername(username: string) {
|
||||
const result = await this.db.query<UserRow>(
|
||||
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = $1`,
|
||||
[username],
|
||||
);
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
findById(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, username, password_hash, token_version, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.get(userId) as UserRow | undefined;
|
||||
return toUserRecord(row);
|
||||
async findByPhoneNumber(phoneNumber: string) {
|
||||
const result = await this.db.query<UserRow>(
|
||||
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
|
||||
FROM users
|
||||
WHERE phone_number = $1`,
|
||||
[phoneNumber],
|
||||
);
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
create(username: string, passwordHash: string) {
|
||||
async findById(userId: string) {
|
||||
const result = await this.db.query<UserRow>(
|
||||
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async create(username: string, passwordHash: string) {
|
||||
const now = new Date().toISOString();
|
||||
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.run(id, username, passwordHash, now, now);
|
||||
const result = await this.db.query<UserRow>(
|
||||
`INSERT INTO users (
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
token_version,
|
||||
display_name,
|
||||
login_provider,
|
||||
account_status,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 1, $4, 'password', 'active', $5, $6)
|
||||
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
|
||||
[id, username, passwordHash, username, now, now],
|
||||
);
|
||||
|
||||
return this.findById(id);
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
incrementTokenVersion(userId: string) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE users
|
||||
SET token_version = token_version + 1, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(new Date().toISOString(), userId);
|
||||
async createPhoneUser(input: CreatePhoneUserInput) {
|
||||
const now = new Date().toISOString();
|
||||
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
|
||||
|
||||
return this.findById(userId);
|
||||
const result = await this.db.query<UserRow>(
|
||||
`INSERT INTO users (
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
token_version,
|
||||
display_name,
|
||||
login_provider,
|
||||
account_status,
|
||||
phone_number,
|
||||
phone_verified_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 1, $4, 'phone', 'active', $5, $6, $7, $8)
|
||||
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
|
||||
[
|
||||
id,
|
||||
input.username,
|
||||
input.passwordHash,
|
||||
input.displayName,
|
||||
input.phoneNumber,
|
||||
input.phoneVerifiedAt,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async createWechatPendingUser(input: CreateWechatPendingUserInput) {
|
||||
const now = new Date().toISOString();
|
||||
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
|
||||
|
||||
const result = await this.db.query<UserRow>(
|
||||
`INSERT INTO users (
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
token_version,
|
||||
display_name,
|
||||
login_provider,
|
||||
account_status,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 1, $4, 'wechat', 'pending_bind_phone', $5, $6)
|
||||
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
|
||||
[id, input.username, input.passwordHash, input.displayName, now, now],
|
||||
);
|
||||
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async activatePendingWechatUser(
|
||||
userId: string,
|
||||
params: {
|
||||
displayName: string;
|
||||
phoneNumber: string;
|
||||
phoneVerifiedAt: string;
|
||||
},
|
||||
) {
|
||||
const result = await this.db.query<UserRow>(
|
||||
`UPDATE users
|
||||
SET account_status = 'active',
|
||||
phone_number = $1,
|
||||
phone_verified_at = $2,
|
||||
display_name = $3,
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
|
||||
[
|
||||
params.phoneNumber,
|
||||
params.phoneVerifiedAt,
|
||||
params.displayName,
|
||||
new Date().toISOString(),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async updatePhoneInfo(
|
||||
userId: string,
|
||||
params: {
|
||||
phoneNumber: string;
|
||||
phoneVerifiedAt: string;
|
||||
displayName?: string;
|
||||
},
|
||||
) {
|
||||
const result = await this.db.query<UserRow>(
|
||||
`UPDATE users
|
||||
SET phone_number = $1,
|
||||
phone_verified_at = $2,
|
||||
display_name = COALESCE($3, display_name),
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
|
||||
[
|
||||
params.phoneNumber,
|
||||
params.phoneVerifiedAt,
|
||||
params.displayName ?? null,
|
||||
new Date().toISOString(),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
async deleteUser(userId: string) {
|
||||
await this.db.query(
|
||||
`DELETE FROM users
|
||||
WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
|
||||
async incrementTokenVersion(userId: string) {
|
||||
const result = await this.db.query<UserRow>(
|
||||
`UPDATE users
|
||||
SET token_version = token_version + 1, updated_at = $1
|
||||
WHERE id = $2
|
||||
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
|
||||
[new Date().toISOString(), userId],
|
||||
);
|
||||
|
||||
return toUserRecord(result.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
214
server-node/src/repositories/userSessionRepository.ts
Normal file
214
server-node/src/repositories/userSessionRepository.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user