feat: add organization schema

This commit is contained in:
2026-02-06 14:00:55 +08:00
parent c8dc3b19a5
commit 96e8440a35
16 changed files with 533 additions and 415 deletions

View File

@@ -11,6 +11,7 @@
"start": "node dist/app.js", "start": "node dist/app.js",
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:migrate-reset": "prisma migrate reset",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"test": "vitest", "test": "vitest",

View File

@@ -1,12 +1,11 @@
model AuthIdentity { model AuthIdentity {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
password String password String
isVerified Boolean @default(false) isVerified Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
organizationMemberships OrganizationUserMembership[] memberships OrganizationMember[]
verifications AuthVerification[] verifications AuthVerification[]
sentInvitations OrganizationInvitation[]
} }
model AuthVerification { model AuthVerification {

View File

@@ -0,0 +1,42 @@
model OrganizationMember {
id String @id @default(uuid())
identityId String
organizationId String
roleId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
identity AuthIdentity @relation(fields: [identityId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id])
role OrganizationRole @relation(fields: [roleId], references: [id])
}
model OrganizationRole {
id String @id @default(uuid())
organizationId String
name String
permissions Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
members OrganizationMember[]
}
model OrganizationInvite {
id String @id @default(uuid())
recipientEmail String
organizationId String
roleId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
}
model Organization {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationRoles OrganizationRole[]
organizationInvites OrganizationInvite[]
members OrganizationMember[]
}

View File

@@ -1,36 +0,0 @@
model OrganizationInvitation {
id String @id @default(uuid())
senderId String
organizationId String
inviteToken String
emailRecipient String
createdAt DateTime @default(now())
acceptedAt DateTime?
isAccepted Boolean @default(false)
isRevoked Boolean @default(false)
organization Organization @relation(fields: [organizationId], references: [id])
inviteSender AuthIdentity @relation(fields: [senderId], references: [id])
}
model Organization {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
organizationUsers OrganizationUserMembership[]
invitations OrganizationInvitation[]
}
model OrganizationUserMembership {
id String @id @default(uuid())
organizationId String
identityId String
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id])
identity AuthIdentity @relation(fields: [identityId], references: [id])
}
enum OrganizationRole {
OWNER
ADMIN
MEMBER
}

View File

@@ -0,0 +1,5 @@
export class InvalidEmailFormat extends Error {
constructor() {
super("Invalid email format");
}
}

View File

@@ -0,0 +1,5 @@
export class OrganizationMismatch extends Error {
constructor() {
super("Organization is not the same as the current organization");
}
}

View File

@@ -0,0 +1,30 @@
import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js";
import { OrganizationMismatch } from "./errors/OrganizationMismatch.js";
import type { OrganizationEntity } from "./organization.entity.js";
import type { OrganizationRoleEntity } from "./organization-role.entity.js";
export class OrganizationInviteEntity {
constructor(
public id: string,
public recipientEmail: string,
public createdAt: Date,
public updatedAt: Date,
public role: OrganizationRoleEntity,
public organization: OrganizationEntity,
) {
if (!recipientEmail.includes("@")) {
throw new InvalidEmailFormat();
}
if (organization.id !== role.organization.id) {
throw new OrganizationMismatch();
}
}
changeRole(newRole: OrganizationRoleEntity) {
if (this.organization.id !== newRole.organization.id) {
throw new OrganizationMismatch();
}
this.role = newRole;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,26 @@
import { OrganizationMismatch } from "./errors/OrganizationMismatch.js";
import type { OrganizationEntity } from "./organization.entity.js";
import type { OrganizationRoleEntity } from "./organization-role.entity.js";
export class OrganizationMemberEntity {
constructor(
public id: string,
public createdAt: Date,
public updatedAt: Date,
public identityId: string, // cross-domain
public organization: OrganizationEntity,
public role: OrganizationRoleEntity,
) {
if (organization.id !== role.organization.id) {
throw new OrganizationMismatch();
}
}
changeRole(newRole: OrganizationRoleEntity) {
if (this.organization.id !== newRole.organization.id) {
throw new OrganizationMismatch();
}
this.role = newRole;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,12 @@
import type { OrganizationEntity } from "./organization.entity.js";
export class OrganizationRoleEntity {
constructor(
public id: string,
public name: string,
public permissions: string[],
public createdAt: Date,
public updatedAt: Date,
public organization: OrganizationEntity
) { }
}

View File

@@ -0,0 +1,28 @@
import { OrganizationMismatch } from "./errors/OrganizationMismatch.js";
import type { OrganizationMemberEntity } from "./organization-member.entity.js";
import type { OrganizationRoleEntity } from "./organization-role.entity.js";
export class OrganizationEntity {
constructor(
public id: string,
public name: string,
public createdAt: Date,
public updatedAt: Date,
public members: OrganizationMemberEntity[],
public roles: OrganizationRoleEntity[],
) { }
addMember(member: OrganizationMemberEntity) {
if (this.id !== member.organization.id) {
throw new OrganizationMismatch();
}
this.members.push(member);
}
addRole(role: OrganizationRoleEntity) {
if (this.id !== role.organization.id) {
throw new OrganizationMismatch();
}
this.roles.push(role);
}
}

View File

@@ -0,0 +1,5 @@
import type { IBaseRepository } from "@/shared/core/IBaseRepository.js";
import type { OrganizationEntity } from "./organization.entity.js";
export interface IOrganizationRepository
extends IBaseRepository<OrganizationEntity> { }

View File

@@ -0,0 +1 @@
export const OrganizationDomain = {};

View File

@@ -1,3 +1,3 @@
import { ContainerModule } from "inversify"; import { ContainerModule } from "inversify";
// biome-ignore lint/correctness/noUnusedFunctionParameters: This bounded context is empty. // biome-ignore lint/correctness/noUnusedFunctionParameters: This bounded context is empty.
export const UserDIModule = new ContainerModule(({ bind }) => {}); export const OrganizationDIModule = new ContainerModule(({ bind }) => { });

View File

@@ -1 +0,0 @@
export const UserDomain = {};

View File

@@ -1,12 +1,12 @@
import { Container } from "inversify"; import { Container } from "inversify";
import { AuthDIModule } from "@/modules/auth/infrastructure/di/auth.di.js"; import { AuthDIModule } from "@/modules/auth/infrastructure/di/auth.di.js";
import { UserDIModule } from "@/modules/user/infrastructure/di/user.di.js"; import { OrganizationDIModule } from "@/modules/organization/infrastructure/di/organization.di.js";
import { SharedDIModule } from "./shared.di.js"; import { SharedDIModule } from "./shared.di.js";
const appContainer = new Container(); const appContainer = new Container();
appContainer.load(SharedDIModule); appContainer.load(SharedDIModule);
appContainer.load(AuthDIModule); appContainer.load(AuthDIModule);
appContainer.load(UserDIModule); appContainer.load(OrganizationDIModule);
export { appContainer }; export { appContainer };

735
yarn.lock

File diff suppressed because it is too large Load Diff