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:
1851
package-lock.json
generated
1851
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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
94
prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
20
src/lib/__tests__/setup.test.ts
Normal file
20
src/lib/__tests__/setup.test.ts
Normal 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
25
src/lib/prisma.ts
Normal 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
16
vitest.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user