feat(01-01): Prisma schema with Tenant/User models and Vitest test setup

- prisma/schema.prisma with Tenant, User, Role, TenantStatus models
  - tenantId on all tenant-scoped models (RLS-ready convention)
  - @@unique([email, tenantId]) and @@index([tenantId]) on User
  - Grace period fields on Tenant (suspendedAt, gracePeriodEndsAt)
  - RLS comment block documenting tenantId convention for future models
- src/lib/prisma.ts singleton PrismaClient pattern (hot-reload safe)
- vitest.config.ts with node environment and @/* path alias
- src/lib/__tests__/setup.test.ts smoke test (2 tests passing)
- package.json scripts: test, test:watch, db:push, db:generate, db:studio
- Schema synced to PostgreSQL 16 via prisma db push
This commit is contained in:
kevin-asprec
2026-03-04 18:31:04 +08:00
parent 90bc5836fd
commit 1adeab2fbc
6 changed files with 2012 additions and 7 deletions

94
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,94 @@
// =============================================================================
// NetForge Prisma Schema
// =============================================================================
//
// MULTI-TENANCY & RLS CONVENTION:
// All tenant-scoped models MUST include a `tenantId` field.
// This field is the foundation for Row-Level Security (RLS) policies.
// When adding new models (Subscriber, Invoice, Plan, Payment, etc.),
// always include: tenantId String + @@index([tenantId])
//
// Super-admin models that span tenants (e.g., audit logs, platform config)
// are the only exception to this rule.
// =============================================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =============================================================================
// ENUMS
// =============================================================================
enum TenantStatus {
ACTIVE
PENDING_SUSPENSION
SUSPENDED
}
enum Role {
ADMIN
OFFICE_STAFF
COLLECTOR
TECHNICIAN
CLIENT
}
// =============================================================================
// MODELS
// =============================================================================
/// A Tenant represents a single ISP business using the NetForge platform.
/// All tenant-scoped data is isolated by tenantId (RLS-ready).
model Tenant {
id String @id @default(uuid())
name String
/// URL-friendly identifier, auto-generated from name (e.g., "my-isp" from "My ISP")
slug String @unique
ownerEmail String
status TenantStatus @default(ACTIVE)
/// Timestamp when suspension was triggered (starts grace period clock)
suspendedAt DateTime?
/// When actual service interruption occurs (suspendedAt + 7 days grace period)
gracePeriodEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
}
/// A User belongs to a Tenant (or is a super-admin with no tenant).
/// Email uniqueness is enforced per-tenant, not globally.
/// Super-admins have isSuperAdmin=true and tenantId=null.
model User {
id String @id @default(uuid())
email String
passwordHash String
firstName String
lastName String
/// Nullable for super-admins who are not scoped to a specific tenant
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
/// Multi-role support — a user can hold more than one role within a tenant
roles Role[]
isActive Boolean @default(true)
isSuperAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Email must be unique within a tenant (super-admins have tenantId=null)
@@unique([email, tenantId])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}