Skip to content

Commit 49fb942

Browse files
committed
feat(server): userSession model
1 parent 289c925 commit 49fb942

File tree

3 files changed

+344
-1
lines changed

3 files changed

+344
-1
lines changed
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { TestingModule } from '@nestjs/testing';
2+
import { PrismaClient } from '@prisma/client';
3+
import ava, { TestFn } from 'ava';
4+
5+
import { Config } from '../../base/config';
6+
import { UserModel } from '../../models/user';
7+
import { UserSessionModel } from '../../models/user-session';
8+
import { createTestingModule, initTestingDB } from '../utils';
9+
10+
interface Context {
11+
module: TestingModule;
12+
user: UserModel;
13+
userSession: UserSessionModel;
14+
db: PrismaClient;
15+
config: Config;
16+
}
17+
18+
const test = ava as TestFn<Context>;
19+
20+
test.before(async t => {
21+
const module = await createTestingModule({
22+
providers: [UserModel, UserSessionModel],
23+
});
24+
25+
t.context.user = module.get(UserModel);
26+
t.context.userSession = module.get(UserSessionModel);
27+
t.context.module = module;
28+
t.context.db = t.context.module.get(PrismaClient);
29+
t.context.config = t.context.module.get(Config);
30+
});
31+
32+
test.beforeEach(async t => {
33+
await initTestingDB(t.context.module.get(PrismaClient));
34+
});
35+
36+
test.after(async t => {
37+
await t.context.module.close();
38+
});
39+
40+
test('should create a new userSession', async t => {
41+
const user = await t.context.user.create({
42+
43+
});
44+
const session = await t.context.db.session.create({
45+
data: {},
46+
});
47+
const userSession = await t.context.userSession.createOrRefresh(
48+
session.id,
49+
user.id
50+
);
51+
t.is(userSession.sessionId, session.id);
52+
t.is(userSession.userId, user.id);
53+
t.not(userSession.expiresAt, null);
54+
});
55+
56+
test('should refresh exists userSession', async t => {
57+
const user = await t.context.user.create({
58+
59+
});
60+
const session = await t.context.db.session.create({
61+
data: {},
62+
});
63+
const userSession = await t.context.userSession.createOrRefresh(
64+
session.id,
65+
user.id
66+
);
67+
t.is(userSession.sessionId, session.id);
68+
t.is(userSession.userId, user.id);
69+
t.not(userSession.expiresAt, null);
70+
71+
const existsUserSession = await t.context.userSession.createOrRefresh(
72+
session.id,
73+
user.id
74+
);
75+
t.is(existsUserSession.sessionId, session.id);
76+
t.is(existsUserSession.userId, user.id);
77+
t.not(existsUserSession.expiresAt, null);
78+
t.is(existsUserSession.id, userSession.id);
79+
t.assert(
80+
existsUserSession.expiresAt!.getTime() > userSession.expiresAt!.getTime()
81+
);
82+
});
83+
84+
test('should not refresh userSession when expires time not hit ttr', async t => {
85+
const user = await t.context.user.create({
86+
87+
});
88+
const session = await t.context.db.session.create({
89+
data: {},
90+
});
91+
const userSession = await t.context.userSession.createOrRefresh(
92+
session.id,
93+
user.id
94+
);
95+
let newExpiresAt = await t.context.userSession.refreshIfNeeded(userSession);
96+
t.is(newExpiresAt, undefined);
97+
userSession.expiresAt = new Date(
98+
userSession.expiresAt!.getTime() - t.context.config.auth.session.ttr * 1000
99+
);
100+
newExpiresAt = await t.context.userSession.refreshIfNeeded(userSession);
101+
t.is(newExpiresAt, undefined);
102+
});
103+
104+
test('should not refresh userSession when expires time hit ttr', async t => {
105+
const user = await t.context.user.create({
106+
107+
});
108+
const session = await t.context.db.session.create({
109+
data: {},
110+
});
111+
const userSession = await t.context.userSession.createOrRefresh(
112+
session.id,
113+
user.id
114+
);
115+
const ttr = t.context.config.auth.session.ttr * 2;
116+
userSession.expiresAt = new Date(
117+
userSession.expiresAt!.getTime() - ttr * 1000
118+
);
119+
const newExpiresAt = await t.context.userSession.refreshIfNeeded(userSession);
120+
t.not(newExpiresAt, undefined);
121+
});
122+
123+
test('should find userSessions without user property by default', async t => {
124+
const session = await t.context.db.session.create({
125+
data: {},
126+
});
127+
const count = 10;
128+
for (let i = 0; i < count; i++) {
129+
const user = await t.context.user.create({
130+
email: `test${i}@affine.pro`,
131+
});
132+
await t.context.userSession.createOrRefresh(session.id, user.id);
133+
}
134+
const userSessions = await t.context.userSession.findManyBySessionId(
135+
session.id
136+
);
137+
t.is(userSessions.length, count);
138+
for (const userSession of userSessions) {
139+
t.is(userSession.sessionId, session.id);
140+
t.is(userSession.user, undefined);
141+
}
142+
});
143+
144+
test('should find userSessions include user property', async t => {
145+
const session = await t.context.db.session.create({
146+
data: {},
147+
});
148+
const count = 10;
149+
for (let i = 0; i < count; i++) {
150+
const user = await t.context.user.create({
151+
email: `test${i}@affine.pro`,
152+
});
153+
await t.context.userSession.createOrRefresh(session.id, user.id);
154+
}
155+
const userSessions = await t.context.userSession.findManyBySessionId(
156+
session.id,
157+
{ user: true }
158+
);
159+
t.is(userSessions.length, count);
160+
for (const userSession of userSessions) {
161+
t.is(userSession.sessionId, session.id);
162+
t.not(userSession.user, undefined);
163+
}
164+
});
165+
166+
test('should delete userSession success by userId', async t => {
167+
const user = await t.context.user.create({
168+
169+
});
170+
const session = await t.context.db.session.create({
171+
data: {},
172+
});
173+
await t.context.userSession.createOrRefresh(session.id, user.id);
174+
let count = await t.context.userSession.delete(user.id);
175+
t.is(count, 1);
176+
count = await t.context.userSession.delete(user.id);
177+
t.is(count, 0);
178+
});
179+
180+
test('should delete userSession success by userId and sessionId', async t => {
181+
const user = await t.context.user.create({
182+
183+
});
184+
const session = await t.context.db.session.create({
185+
data: {},
186+
});
187+
await t.context.userSession.createOrRefresh(session.id, user.id);
188+
const count = await t.context.userSession.delete(user.id, session.id);
189+
t.is(count, 1);
190+
});
191+
192+
test('should delete userSession fail when sessionId not match', async t => {
193+
const user = await t.context.user.create({
194+
195+
});
196+
const session = await t.context.db.session.create({
197+
data: {},
198+
});
199+
await t.context.userSession.createOrRefresh(session.id, user.id);
200+
const count = await t.context.userSession.delete(
201+
user.id,
202+
'not-exists-session-id'
203+
);
204+
t.is(count, 0);
205+
});
206+
207+
test('should cleanup expired userSessions', async t => {
208+
const user = await t.context.user.create({
209+
210+
});
211+
const session = await t.context.db.session.create({
212+
data: {},
213+
});
214+
const userSession = await t.context.userSession.createOrRefresh(
215+
session.id,
216+
user.id
217+
);
218+
await t.context.userSession.cleanExpiredUserSessions();
219+
let count = await t.context.db.userSession.count();
220+
t.is(count, 1);
221+
222+
// Set expiresAt to past time
223+
await t.context.db.userSession.update({
224+
where: { id: userSession.id },
225+
data: { expiresAt: new Date('2022-01-01') },
226+
});
227+
await t.context.userSession.cleanExpiredUserSessions();
228+
count = await t.context.db.userSession.count();
229+
t.is(count, 0);
230+
});

packages/backend/server/src/models/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Global, Injectable, Module } from '@nestjs/common';
22

33
import { UserModel } from './user';
4+
import { UserSessionModel } from './user-session';
45

5-
const models = [UserModel] as const;
6+
const models = [UserModel, UserSessionModel] as const;
67

78
@Injectable()
89
export class Models {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import {
3+
Prisma,
4+
PrismaClient,
5+
type User,
6+
type UserSession as _UserSession,
7+
} from '@prisma/client';
8+
9+
import { Config } from '../base';
10+
11+
export type UserSession = _UserSession & { user?: User };
12+
13+
@Injectable()
14+
export class UserSessionModel {
15+
private readonly logger = new Logger(UserSessionModel.name);
16+
constructor(
17+
private readonly db: PrismaClient,
18+
private readonly config: Config
19+
) {}
20+
21+
async createOrRefresh(
22+
sessionId: string,
23+
userId: string,
24+
ttl = this.config.auth.session.ttl
25+
) {
26+
const expiresAt = new Date(Date.now() + ttl * 1000);
27+
return await this.db.userSession.upsert({
28+
where: {
29+
sessionId_userId: {
30+
sessionId,
31+
userId,
32+
},
33+
},
34+
update: {
35+
expiresAt,
36+
},
37+
create: {
38+
sessionId,
39+
userId,
40+
expiresAt,
41+
},
42+
});
43+
}
44+
45+
async refreshIfNeeded(
46+
userSession: UserSession,
47+
ttr = this.config.auth.session.ttr
48+
): Promise<Date | undefined> {
49+
if (
50+
userSession.expiresAt &&
51+
userSession.expiresAt.getTime() - Date.now() > ttr * 1000
52+
) {
53+
// no need to refresh
54+
return;
55+
}
56+
57+
const newExpiresAt = new Date(
58+
Date.now() + this.config.auth.session.ttl * 1000
59+
);
60+
await this.db.userSession.update({
61+
where: {
62+
id: userSession.id,
63+
},
64+
data: {
65+
expiresAt: newExpiresAt,
66+
},
67+
});
68+
69+
// return the new expiresAt after refresh
70+
return newExpiresAt;
71+
}
72+
73+
async findManyBySessionId(
74+
sessionId: string,
75+
include?: Prisma.UserSessionInclude
76+
): Promise<UserSession[]> {
77+
return await this.db.userSession.findMany({
78+
where: {
79+
sessionId,
80+
OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null }],
81+
},
82+
orderBy: {
83+
createdAt: 'asc',
84+
},
85+
include,
86+
});
87+
}
88+
89+
async delete(userId: string, sessionId?: string) {
90+
const result = await this.db.userSession.deleteMany({
91+
where: {
92+
userId,
93+
sessionId,
94+
},
95+
});
96+
this.logger.log(
97+
`Deleted ${result.count} user sessions by userId: ${userId} and sessionId: ${sessionId}`
98+
);
99+
return result.count;
100+
}
101+
102+
async cleanExpiredUserSessions() {
103+
const result = await this.db.userSession.deleteMany({
104+
where: {
105+
expiresAt: {
106+
lte: new Date(),
107+
},
108+
},
109+
});
110+
this.logger.log(`Cleaned ${result.count} expired user sessions`);
111+
}
112+
}

0 commit comments

Comments
 (0)