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

1851
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,17 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^6.19.2",
"next": "16.1.6",
"prisma": "^6.19.2",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -18,9 +25,11 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.18"
}
}

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])
}

View File

@@ -0,0 +1,20 @@
/**
* Smoke test: verifies that the Prisma client singleton is importable
* and defined without needing an active database connection.
*
* This test validates the import chain:
* src/lib/prisma.ts -> @prisma/client -> generated Prisma client
*/
import { prisma } from "@/lib/prisma";
describe("Prisma client singleton", () => {
it("should be defined and importable", () => {
expect(prisma).toBeDefined();
});
it("should be a PrismaClient instance", () => {
// PrismaClient instances have $connect and $disconnect methods
expect(typeof prisma.$connect).toBe("function");
expect(typeof prisma.$disconnect).toBe("function");
});
});

25
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,25 @@
import { PrismaClient } from "@prisma/client";
// Singleton pattern to prevent multiple PrismaClient instances during Next.js
// hot-module replacement in development. Without this, each hot reload creates
// a new PrismaClient and exhausts the PostgreSQL connection pool.
//
// In production, module caching ensures a single instance per process.
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma =
globalThis.__prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalThis.__prisma = prisma;
}

16
vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});