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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
@@ -18,9 +25,11 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"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