Skip to content

Commit 2acfdba

Browse files
committed
feat(server): userSession model
1 parent 8e8058a commit 2acfdba

File tree

3 files changed

+321
-1
lines changed

3 files changed

+321
-1
lines changed

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

0 commit comments

Comments
 (0)