From 5dcbae6f8687a79aed4744c6e417806ff5bd7a4f Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Tue, 18 Mar 2025 00:41:21 +0000 Subject: [PATCH] feat(server): add settings model (#10755) close CLOUD-166 --- .../20250311143215_add_settings/migration.sql | 12 +++ packages/backend/server/schema.prisma | 12 +++ .../src/models/__tests__/settings.spec.ts | 98 +++++++++++++++++++ packages/backend/server/src/models/index.ts | 3 + .../backend/server/src/models/settings.ts | 51 ++++++++++ 5 files changed, 176 insertions(+) create mode 100644 packages/backend/server/migrations/20250311143215_add_settings/migration.sql create mode 100644 packages/backend/server/src/models/__tests__/settings.spec.ts create mode 100644 packages/backend/server/src/models/settings.ts diff --git a/packages/backend/server/migrations/20250311143215_add_settings/migration.sql b/packages/backend/server/migrations/20250311143215_add_settings/migration.sql new file mode 100644 index 0000000000..bbdd36cbaa --- /dev/null +++ b/packages/backend/server/migrations/20250311143215_add_settings/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "settings" ( + "user_id" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + "payload" JSONB NOT NULL, + + CONSTRAINT "settings_pkey" PRIMARY KEY ("user_id") +); + +-- AddForeignKey +ALTER TABLE "settings" ADD CONSTRAINT "settings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index ceb9b8a704..7b6080839e 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -39,6 +39,7 @@ model User { createdHistory SnapshotHistory[] @relation("createdHistory") // receive notifications notifications Notification[] @relation("user_notifications") + settings Settings? @@index([email]) @@map("users") @@ -706,3 +707,14 @@ model Notification { @@index([userId, createdAt, read]) @@map("notifications") } + +model Settings { + userId String @id @map("user_id") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + payload Json @db.JsonB + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("settings") +} diff --git a/packages/backend/server/src/models/__tests__/settings.spec.ts b/packages/backend/server/src/models/__tests__/settings.spec.ts new file mode 100644 index 0000000000..c7c3624af3 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/settings.spec.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto'; +import { mock } from 'node:test'; + +import ava, { TestFn } from 'ava'; +import { ZodError } from 'zod'; + +import { createTestingModule, type TestingModule } from '../../__tests__/utils'; +import { Config } from '../../base/config'; +import { Models, User } from '..'; + +interface Context { + config: Config; + module: TestingModule; + models: Models; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule(); + + t.context.models = module.get(Models); + t.context.config = module.get(Config); + t.context.module = module; + await t.context.module.initTestingDB(); +}); + +let user: User; + +test.beforeEach(async t => { + user = await t.context.models.user.create({ + email: `test-${randomUUID()}@affine.pro`, + }); +}); + +test.afterEach.always(() => { + mock.reset(); + mock.timers.reset(); +}); + +test.after(async t => { + await t.context.module.close(); +}); + +test('should get a user settings with default value', async t => { + const settings = await t.context.models.settings.get(user.id); + t.deepEqual(settings, { + receiveInvitationEmail: true, + receiveMentionEmail: true, + }); +}); + +test('should update a user settings', async t => { + const settings = await t.context.models.settings.set(user.id, { + receiveInvitationEmail: false, + }); + t.deepEqual(settings, { + receiveInvitationEmail: false, + receiveMentionEmail: true, + }); + const settings2 = await t.context.models.settings.get(user.id); + t.deepEqual(settings2, settings); + + // update existing setting + const setting3 = await t.context.models.settings.set(user.id, { + receiveInvitationEmail: true, + }); + t.deepEqual(setting3, { + receiveInvitationEmail: true, + receiveMentionEmail: true, + }); + const setting4 = await t.context.models.settings.get(user.id); + t.deepEqual(setting4, setting3); + + const setting5 = await t.context.models.settings.set(user.id, { + receiveMentionEmail: false, + receiveInvitationEmail: false, + }); + t.deepEqual(setting5, { + receiveInvitationEmail: false, + receiveMentionEmail: false, + }); + const setting6 = await t.context.models.settings.get(user.id); + t.deepEqual(setting6, setting5); +}); + +test('should throw error when update settings with invalid payload', async t => { + await t.throwsAsync( + t.context.models.settings.set(user.id, { + // @ts-expect-error invalid setting input types + receiveInvitationEmail: 1, + }), + { + instanceOf: ZodError, + message: /Expected boolean, received number/, + } + ); +}); diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 53858f80f7..571b449441 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -14,6 +14,7 @@ import { HistoryModel } from './history'; import { NotificationModel } from './notification'; import { MODELS_SYMBOL } from './provider'; import { SessionModel } from './session'; +import { SettingsModel } from './settings'; import { UserModel } from './user'; import { UserDocModel } from './user-doc'; import { UserFeatureModel } from './user-feature'; @@ -36,6 +37,7 @@ const MODELS = { docUser: DocUserModel, history: HistoryModel, notification: NotificationModel, + settings: SettingsModel, }; type ModelsType = { @@ -94,6 +96,7 @@ export * from './feature'; export * from './history'; export * from './notification'; export * from './session'; +export * from './settings'; export * from './user'; export * from './user-doc'; export * from './user-feature'; diff --git a/packages/backend/server/src/models/settings.ts b/packages/backend/server/src/models/settings.ts new file mode 100644 index 0000000000..3a44b2acfd --- /dev/null +++ b/packages/backend/server/src/models/settings.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import z from 'zod'; + +import { BaseModel } from './base'; + +export const SettingsSchema = z.object({ + receiveInvitationEmail: z.boolean().default(true), + receiveMentionEmail: z.boolean().default(true), +}); + +export type SettingsInput = z.input; +export type Settings = z.infer; + +/** + * Settings Model + */ +@Injectable() +export class SettingsModel extends BaseModel { + @Transactional() + async set(userId: string, setting: SettingsInput) { + const existsSetting = await this.get(userId); + const payload = SettingsSchema.parse({ + ...existsSetting, + ...setting, + }); + await this.db.settings.upsert({ + where: { + userId, + }, + update: { + payload, + }, + create: { + userId, + payload, + }, + }); + this.logger.log(`Settings updated for user ${userId}`); + return payload; + } + + async get(userId: string): Promise { + const row = await this.db.settings.findUnique({ + where: { + userId, + }, + }); + return SettingsSchema.parse(row?.payload ?? {}); + } +}