website refactor

This commit is contained in:
2026-01-12 19:24:59 +01:00
parent 1f0c4f7fa6
commit 5ea95eaf51
54 changed files with 2894 additions and 2342 deletions

View File

@@ -29,13 +29,29 @@
},
{
"files": [
"lib/presenters/*.ts",
"lib/presenters/*.tsx"
"lib/builders/view-models/*.ts",
"lib/builders/view-models/*.tsx"
],
"rules": {
"gridpilot-rules/presenter-contract": "error",
"gridpilot-rules/presenter-purity": "error",
"gridpilot-rules/filename-presenter-match": "error"
"gridpilot-rules/view-model-builder-contract": "error"
}
},
{
"files": [
"lib/builders/view-data/*.ts",
"lib/builders/view-data/*.tsx"
],
"rules": {
"gridpilot-rules/view-data-builder-contract": "error"
}
},
{
"files": [
"lib/mutations/**/*.ts"
],
"rules": {
"gridpilot-rules/mutation-contract": "error",
"gridpilot-rules/filename-service-match": "error"
}
},
{
@@ -104,12 +120,20 @@
"gridpilot-rules/page-query-return-type": "error"
}
},
{
"files": [
"templates/**/*.ts",
"templates/**/*.tsx"
],
"rules": {
"gridpilot-rules/view-data-location": "error"
}
},
{
"files": [
"lib/services/**/*.ts"
],
"rules": {
"gridpilot-rules/services-must-be-marked": "error",
"gridpilot-rules/services-no-external-api": "error",
"gridpilot-rules/services-must-be-pure": "error",
"gridpilot-rules/filename-service-match": "error"
@@ -117,11 +141,13 @@
},
{
"files": [
"app/**/*.tsx"
"app/**/*.tsx",
"app/**/*.ts"
],
"rules": {
"gridpilot-rules/client-only-no-server-code": "error",
"gridpilot-rules/client-only-must-have-directive": "error"
"gridpilot-rules/client-only-must-have-directive": "error",
"gridpilot-rules/server-actions-must-use-mutations": "error"
}
},
{

View File

@@ -0,0 +1,58 @@
'use client';
import { useState, useEffect } from 'react';
import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery';
export function AdminDashboardClient() {
const [viewData, setViewData] = useState<AdminDashboardViewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadStats = async () => {
try {
setLoading(true);
const query = new AdminDashboardPageQuery();
const result = await query.execute();
if (result.status === 'ok') {
// Page Query already returns View Data via builder
setViewData(result.dto);
} else if (result.status === 'notFound') {
// Handle not found - could show a message or redirect
console.error('Access denied - You must be logged in as an Owner or Admin');
} else {
// Handle error - could show a toast or error message
console.error('Failed to load dashboard stats');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load stats';
console.error(message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadStats();
}, []);
if (!viewData) {
return (
<div className="flex flex-col items-center justify-center py-20 space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading dashboard...</div>
</div>
);
}
return (
<AdminDashboardTemplate
viewData={viewData}
onRefresh={loadStats}
isLoading={loading}
/>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate';
import { AdminUsersViewData } from '@/templates/AdminUsersViewData';
import { AdminUsersPresenter } from '@/lib/presenters/AdminUsersPresenter';
import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery';
import { updateUserStatus, deleteUser } from './actions';
export function AdminUsersClient() {
const [viewData, setViewData] = useState<AdminUsersViewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [deletingUser, setDeletingUser] = useState<string | null>(null);
const loadUsers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const query = new AdminUsersPageQuery();
const result = await query.execute({
search: search || undefined,
role: roleFilter || undefined,
status: statusFilter || undefined,
page: 1,
limit: 50,
});
if (result.status === 'ok') {
const data = AdminUsersPresenter.present(result.dto);
setViewData(data);
} else if (result.status === 'notFound') {
setError('Access denied - You must be logged in as an Owner or Admin');
} else {
setError('Failed to load users');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load users';
setError(message);
} finally {
setLoading(false);
}
}, [search, roleFilter, statusFilter]);
useEffect(() => {
const timeout = setTimeout(() => {
loadUsers();
}, 300);
return () => clearTimeout(timeout);
}, [loadUsers]);
const handleUpdateStatus = async (userId: string, newStatus: string) => {
try {
await updateUserStatus(userId, newStatus);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update status');
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
return;
}
try {
setDeletingUser(userId);
await deleteUser(userId);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
setDeletingUser(null);
}
};
const handleClearFilters = () => {
setSearch('');
setRoleFilter('');
setStatusFilter('');
};
if (!viewData) {
return (
<div className="flex flex-col items-center justify-center py-20 space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading users...</div>
</div>
);
}
return (
<AdminUsersTemplate
viewData={viewData}
onRefresh={loadUsers}
onSearch={setSearch}
onFilterRole={setRoleFilter}
onFilterStatus={setStatusFilter}
onClearFilters={handleClearFilters}
onUpdateStatus={handleUpdateStatus}
onDeleteUser={handleDeleteUser}
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
loading={loading}
error={error}
deletingUser={deletingUser}
/>
);
}

View File

@@ -0,0 +1,45 @@
'use server';
import { UpdateUserStatusMutation } from '@/lib/mutations/admin/UpdateUserStatusMutation';
import { DeleteUserMutation } from '@/lib/mutations/admin/DeleteUserMutation';
import { revalidatePath } from 'next/cache';
/**
* Server actions for admin operations
*
* All write operations must enter through server actions.
* Actions are thin wrappers that handle framework concerns (revalidation).
* Business logic is handled by Mutations.
*/
/**
* Update user status
*/
export async function updateUserStatus(userId: string, status: string): Promise<void> {
try {
const mutation = new UpdateUserStatusMutation();
await mutation.execute({ userId, status });
// Revalidate the users page
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
/**
* Delete user
*/
export async function deleteUser(userId: string): Promise<void> {
try {
const mutation = new DeleteUserMutation();
await mutation.execute({ userId });
// Revalidate the users page
revalidatePath('/admin/users');
} catch (error) {
console.error('deleteUser failed:', error);
throw new Error('Failed to delete user');
}
}

View File

@@ -12,15 +12,11 @@ interface AdminLayoutProps {
* Uses RouteGuard to enforce access control server-side.
*/
export default async function AdminLayout({ children }: AdminLayoutProps) {
console.log('[ADMIN LAYOUT] ========== ADMIN LAYOUT CALLED ==========');
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '/';
console.log('[ADMIN LAYOUT] Pathname:', pathname);
const guard = createRouteGuard();
console.log('[ADMIN LAYOUT] About to call guard.enforce');
await guard.enforce({ pathname });
console.log('[ADMIN LAYOUT] guard.enforce completed successfully');
return (
<div className="min-h-screen bg-deep-graphite">

View File

@@ -1,10 +1,5 @@
import { AdminLayout } from '@/components/admin/AdminLayout';
import { AdminDashboardPage } from '@/components/admin/AdminDashboardPage';
import { AdminDashboardClient } from './AdminDashboardClient';
export default function AdminPage() {
return (
<AdminLayout>
<AdminDashboardPage />
</AdminLayout>
);
return <AdminDashboardClient />;
}

View File

@@ -1,10 +1,5 @@
import { AdminLayout } from '@/components/admin/AdminLayout';
import { AdminUsersPage } from '@/components/admin/AdminUsersPage';
import { AdminUsersClient } from '../AdminUsersClient';
export default function AdminUsers() {
return (
<AdminLayout>
<AdminUsersPage />
</AdminLayout>
);
return <AdminUsersClient />;
}

View File

@@ -26,6 +26,9 @@ const modelTaxonomyRules = require('./model-taxonomy-rules');
const filenameRules = require('./filename-rules');
const componentNoDataManipulation = require('./component-no-data-manipulation');
const presenterPurity = require('./presenter-purity');
const mutationContract = require('./mutation-contract');
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
const viewDataLocation = require('./view-data-location');
module.exports = {
rules: {
@@ -90,6 +93,15 @@ module.exports = {
// Component Data Manipulation Rules
'component-no-data-manipulation': componentNoDataManipulation,
// Mutation Rules
'mutation-contract': mutationContract,
// Server Actions Rules
'server-actions-must-use-mutations': serverActionsMustUseMutations,
// View Data Rules
'view-data-location': viewDataLocation,
},
// Configurations for different use cases
@@ -155,6 +167,15 @@ module.exports = {
// Filename
'gridpilot-rules/filename-presenter-match': 'error',
'gridpilot-rules/filename-service-match': 'error',
// Mutations
'gridpilot-rules/mutation-contract': 'error',
// Server Actions
'gridpilot-rules/server-actions-must-use-mutations': 'error',
// View Data
'gridpilot-rules/view-data-location': 'error',
},
},

View File

@@ -0,0 +1,132 @@
/**
* ESLint Rule: Mutation Contract
*
* Ensures mutations implement the Mutation contract
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure mutations implement the Mutation contract',
category: 'Mutation Contract',
recommended: true,
},
messages: {
noMutationContract: 'Mutations must implement the Mutation interface from lib/contracts/mutations/Mutation.ts',
missingExecute: 'Mutations must have an execute method that takes input and returns Promise',
wrongExecuteSignature: 'Execute method must have signature: execute(input: TInput): Promise<TOutput>',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
// Only apply to mutation files
if (!filename.includes('/lib/mutations/') || !filename.endsWith('.ts')) {
return {};
}
let hasMutationImport = false;
let hasExecuteMethod = false;
let implementsMutation = false;
let executeMethodNode = null;
return {
// Check for Mutation import
ImportDeclaration(node) {
if (node.source.value === '@/lib/contracts/mutations/Mutation') {
hasMutationImport = true;
}
},
// Check for implements clause
ClassDeclaration(node) {
if (node.implements) {
node.implements.forEach(impl => {
if (impl.type === 'Identifier' && impl.name === 'Mutation') {
implementsMutation = true;
}
if (impl.type === 'TSExpressionWithTypeArguments' &&
impl.expression.type === 'Identifier' &&
impl.expression.name === 'Mutation') {
implementsMutation = true;
}
});
}
},
// Check for execute method
MethodDefinition(node) {
if (node.key.type === 'Identifier' && node.key.name === 'execute') {
hasExecuteMethod = true;
executeMethodNode = node;
}
},
'Program:exit'() {
// Skip if file doesn't look like a mutation
const isMutationFile = filename.includes('/lib/mutations/') &&
filename.endsWith('.ts') &&
!filename.endsWith('.test.ts');
if (!isMutationFile) return;
// Check if it's a class-based mutation
const hasClass = filename.includes('/lib/mutations/') &&
!filename.endsWith('.test.ts');
if (hasClass && !hasExecuteMethod) {
if (executeMethodNode) {
context.report({
node: executeMethodNode,
messageId: 'missingExecute',
});
} else {
// Find the class node to report on
const sourceCode = context.getSourceCode();
const classNode = sourceCode.ast.body.find(node =>
node.type === 'ClassDeclaration' &&
node.id &&
node.id.name.endsWith('Mutation')
);
if (classNode) {
context.report({
node: classNode,
messageId: 'missingExecute',
});
}
}
return;
}
// Check for contract implementation (if it's a class)
if (hasClass && !implementsMutation && hasMutationImport) {
// Find the class node to report on
const sourceCode = context.getSourceCode();
const classNode = sourceCode.ast.body.find(node =>
node.type === 'ClassDeclaration' &&
node.id &&
node.id.name.endsWith('Mutation')
);
if (classNode) {
context.report({
node: classNode,
messageId: 'noMutationContract',
});
}
return;
}
// Check execute method signature
if (executeMethodNode && executeMethodNode.value.params.length === 0) {
context.report({
node: executeMethodNode,
messageId: 'wrongExecuteSignature',
});
}
},
};
},
};

View File

@@ -75,10 +75,18 @@ module.exports = {
ClassDeclaration(node) {
const className = node.id?.name;
if (className && className.endsWith('PageQuery')) {
if (!node.implements ||
!node.implements.some(impl =>
impl.expression.type === 'GenericIdentifier' &&
impl.expression.name === 'PageQuery')) {
const hasPageQueryImpl = node.implements && node.implements.some(impl => {
// Handle different AST node types for generic interfaces
if (impl.expression.type === 'TSExpressionWithTypeArguments') {
return impl.expression.expression.name === 'PageQuery';
}
if (impl.expression.type === 'Identifier') {
return impl.expression.name === 'PageQuery';
}
return false;
});
if (!hasPageQueryImpl) {
context.report({
node,
messageId: 'message',

View File

@@ -0,0 +1,89 @@
/**
* ESLint Rule: Page Query Must Use Builder
*
* Ensures page queries use builders to map their results
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure page queries use builders to map their results',
category: 'Page Query',
recommended: true,
},
messages: {
mustUseBuilder: 'Page queries must use Builders to map Page DTO to View Data/View Model. See apps/website/docs/architecture/write/BUILDERS.md',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
// Only apply to page query files
if (!filename.includes('/lib/page-queries/') || !filename.endsWith('.ts')) {
return {};
}
let hasBuilderImport = false;
let hasReturnStatement = false;
return {
// Check imports for builder
ImportDeclaration(node) {
const importPath = node.source.value;
if (importPath.includes('/lib/builders/')) {
hasBuilderImport = true;
}
},
// Check for return statements
ReturnStatement(node) {
hasReturnStatement = true;
},
'Program:exit'() {
// Skip if file doesn't look like a page query
const isPageQueryFile = filename.includes('/lib/page-queries/') &&
filename.endsWith('.ts') &&
!filename.endsWith('.test.ts') &&
!filename.includes('/result/');
if (!isPageQueryFile) return;
// Check if it's a class-based page query
const sourceCode = context.getSourceCode();
const classNode = sourceCode.ast.body.find(node =>
node.type === 'ClassDeclaration' &&
node.id &&
node.id.name.endsWith('PageQuery')
);
if (!classNode) return;
// Check if the class has an execute method
const executeMethod = classNode.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'execute'
);
if (!executeMethod) return;
// Check if the execute method uses a builder
// Look for builder usage in the method body
const methodBody = executeMethod.value.body;
if (!methodBody) return;
// Check if there's a builder import
if (!hasBuilderImport) {
context.report({
node: classNode,
messageId: 'mustUseBuilder',
});
}
},
};
},
};

View File

@@ -275,7 +275,16 @@ module.exports = {
create(context) {
return {
FunctionDeclaration(node) {
if (!node.id.name.startsWith('assert') &&
// Skip if this is the main component (default export or ends with Page/Template)
const filename = context.getFilename();
const isMainComponent =
(node.parent && node.parent.type === 'ExportDefaultDeclaration') ||
(node.id && node.id.name && (node.id.name.endsWith('Page') || node.id.name.endsWith('Template') || node.id.name.endsWith('Component')));
if (isMainComponent) return;
// Only flag nested helper functions
if (!node.id.name.startsWith('assert') &&
!node.id.name.startsWith('invariant') &&
!isInComment(node)) {
context.report({
@@ -285,9 +294,16 @@ module.exports = {
}
},
VariableDeclarator(node) {
if (node.init &&
// Skip if this is the main component
const isMainComponent =
(node.parent && node.parent.parent && node.parent.parent.type === 'ExportDefaultDeclaration') ||
(node.id && node.id.name && (node.id.name.endsWith('Page') || node.id.name.endsWith('Template') || node.id.name.endsWith('Component')));
if (isMainComponent) return;
if (node.init &&
(node.init.type === 'FunctionExpression' || node.init.type === 'ArrowFunctionExpression') &&
!node.id.name.startsWith('assert') &&
!node.id.name.startsWith('assert') &&
!node.id.name.startsWith('invariant') &&
!isInComment(node)) {
context.report({

View File

@@ -0,0 +1,130 @@
/**
* ESLint Rule: Server Actions Must Use Mutations
*
* Ensures server actions use mutations instead of direct service calls
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure server actions use mutations instead of direct service calls',
category: 'Server Actions',
recommended: true,
},
messages: {
mustUseMutations: 'Server actions must use Mutations, not direct Service or API Client calls. See apps/website/docs/architecture/write/MUTATIONS.md',
noDirectService: 'Direct service calls in server actions are not allowed',
noMutationUsage: 'Server actions should instantiate and call mutations',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
const isServerAction = filename.includes('/app/') &&
(filename.endsWith('.ts') || filename.endsWith('.tsx')) &&
!filename.endsWith('.test.ts') &&
!filename.endsWith('.test.tsx');
if (!isServerAction) {
return {};
}
let hasUseServerDirective = false;
let hasServiceImport = false;
let hasApiClientImport = false;
let hasMutationImport = false;
const newExpressions = [];
const callExpressions = [];
return {
// Check for 'use server' directive
ExpressionStatement(node) {
if (node.expression.type === 'Literal' && node.expression.value === 'use server') {
hasUseServerDirective = true;
}
},
// Check imports
ImportDeclaration(node) {
const importPath = node.source.value;
if (importPath.includes('/lib/services/')) {
hasServiceImport = true;
}
if (importPath.includes('/lib/api/') || importPath.includes('/api/')) {
hasApiClientImport = true;
}
if (importPath.includes('/lib/mutations/')) {
hasMutationImport = true;
}
},
// Track all NewExpression (instantiations)
NewExpression(node) {
newExpressions.push(node);
},
// Track all CallExpression (method calls)
CallExpression(node) {
callExpressions.push(node);
},
'Program:exit'() {
// Only check files with 'use server' directive
if (!hasUseServerDirective) return;
// Check for direct service/API client instantiation
const hasDirectServiceInstantiation = newExpressions.some(node => {
if (node.callee.type === 'Identifier') {
const calleeName = node.callee.name;
return calleeName.endsWith('Service') || calleeName.endsWith('ApiClient') || calleeName.endsWith('Client');
}
return false;
});
// Check for direct service method calls
const hasDirectServiceCall = callExpressions.some(node => {
if (node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier') {
const objName = node.callee.object.name;
return objName.endsWith('Service') || objName.endsWith('ApiClient');
}
return false;
});
// Check if mutations are being used
const hasMutationUsage = newExpressions.some(node => {
if (node.callee.type === 'Identifier') {
return node.callee.name.endsWith('Mutation');
}
return false;
});
// Report violations
if (hasDirectServiceInstantiation && !hasMutationUsage) {
context.report({
node: null,
messageId: 'mustUseMutations',
});
}
if (hasDirectServiceCall) {
context.report({
node: null,
messageId: 'noDirectService',
});
}
// If imports exist but no mutation usage
if ((hasServiceImport || hasApiClientImport) && !hasMutationImport) {
context.report({
node: null,
messageId: 'mustUseMutations',
});
}
},
};
},
};

View File

@@ -140,11 +140,59 @@ module.exports = {
},
},
create(context) {
// Helper to recursively check if type contains ViewData
function typeContainsViewData(typeNode) {
if (!typeNode) return false;
// Check direct type name
if (typeNode.type === 'TSTypeReference' &&
typeNode.typeName &&
typeNode.typeName.name &&
typeNode.typeName.name.includes('ViewData')) {
return true;
}
// Check nested in object type
if (typeNode.type === 'TSTypeLiteral' && typeNode.members) {
for (const member of typeNode.members) {
if (member.type === 'TSPropertySignature' &&
member.typeAnnotation &&
typeContainsViewData(member.typeAnnotation.typeAnnotation)) {
return true;
}
}
}
// Check union/intersection types
if (typeNode.type === 'TSUnionType' || typeNode.type === 'TSIntersectionType') {
return typeNode.types.some(t => typeContainsViewData(t));
}
return false;
}
return {
FunctionDeclaration(node) {
if (node.params.length === 0 ||
!node.params[0].typeAnnotation ||
!node.params[0].typeAnnotation.typeAnnotation.type.includes('ViewData')) {
if (node.params.length === 0) {
context.report({
node,
messageId: 'message',
});
return;
}
const firstParam = node.params[0];
if (!firstParam.typeAnnotation || !firstParam.typeAnnotation.typeAnnotation) {
context.report({
node,
messageId: 'message',
});
return;
}
const typeAnnotation = firstParam.typeAnnotation.typeAnnotation;
if (!typeContainsViewData(typeAnnotation)) {
context.report({
node,
messageId: 'message',
@@ -170,9 +218,24 @@ module.exports = {
create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
node.declaration.type === 'VariableDeclaration')) {
// Get the function/variable name
const name = node.declaration.id?.name;
// Allow the main template component (ends with Template)
if (name && name.endsWith('Template')) {
return;
}
// Allow default exports
if (node.declaration.type === 'VariableDeclaration' &&
node.declaration.declarations.some(d => d.id.type === 'Identifier' && d.id.name.endsWith('Template'))) {
return;
}
context.report({
node,
messageId: 'message',

View File

@@ -0,0 +1,50 @@
/**
* ESLint Rule: ViewData Location
*
* Ensures ViewData types are in lib/view-data/, not templates/
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure ViewData types are in lib/view-data/, not templates/',
category: 'File Structure',
recommended: true,
},
messages: {
wrongLocation: 'ViewData types must be in lib/view-data/, not templates/. See apps/website/docs/architecture/website/VIEW_DATA.md',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
return {
ImportDeclaration(node) {
const importPath = node.source.value;
// Check for ViewData imports from templates
if (importPath.includes('/templates/') &&
importPath.includes('ViewData')) {
context.report({
node,
messageId: 'wrongLocation',
});
}
},
// Also check if the file itself is a ViewData in wrong location
Program(node) {
if (filename.includes('/templates/') &&
filename.endsWith('ViewData.ts')) {
context.report({
node,
messageId: 'wrongLocation',
});
}
},
};
},
};

View File

@@ -0,0 +1,26 @@
import { DashboardStats } from '@/lib/api/admin/AdminApiClient';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
/**
* AdminDashboardViewDataBuilder
*
* Server-side builder that transforms API DashboardStats DTO
* directly into ViewData for the AdminDashboardTemplate.
*
* Deterministic, side-effect free.
*/
export class AdminDashboardViewDataBuilder {
static build(apiStats: DashboardStats): AdminDashboardViewData {
return {
stats: {
totalUsers: apiStats.totalUsers,
activeUsers: apiStats.activeUsers,
suspendedUsers: apiStats.suspendedUsers,
deletedUsers: apiStats.deletedUsers,
systemAdmins: apiStats.systemAdmins,
recentLogins: apiStats.recentLogins,
newUsersToday: apiStats.newUsersToday,
},
};
}
}

View File

@@ -0,0 +1,15 @@
'use client';
import { AdminDashboardPageDto } from '@/lib/page-queries/AdminDashboardPageQuery';
import { AdminDashboardViewModel } from '@/lib/view-models/AdminDashboardViewModel';
/**
* AdminDashboardViewModelBuilder
*
* Transforms AdminDashboardPageDto into AdminDashboardViewModel
*/
export class AdminDashboardViewModelBuilder {
static build(dto: AdminDashboardPageDto): AdminDashboardViewModel {
return new AdminDashboardViewModel(dto);
}
}

View File

@@ -0,0 +1,25 @@
/**
* ViewData Builder Contract
*
* Purpose: Transform ViewModels into ViewData for templates
*
* Rules:
* - Deterministic and side-effect free
* - No HTTP/API calls
* - Input: ViewModel
* - Output: ViewData (JSON-serializable)
* - Must be in lib/builders/view-data/
* - Must be named *ViewDataBuilder
* - Must have 'use client' directive
* - Must implement static build() method
*/
export interface ViewDataBuilder<TInput, TOutput> {
/**
* Transform ViewModel into ViewData
*
* @param viewModel - Client-side ViewModel
* @returns ViewData for template
*/
build(viewModel: TInput): TOutput;
}

View File

@@ -0,0 +1,25 @@
/**
* ViewModel Builder Contract
*
* Purpose: Transform API Transport DTOs into ViewModels
*
* Rules:
* - Deterministic and side-effect free
* - No HTTP/API calls
* - Input: API Transport DTO
* - Output: ViewModel
* - Must be in lib/builders/view-models/
* - Must be named *ViewModelBuilder
* - Must have 'use client' directive
* - Must implement static build() method
*/
export interface ViewModelBuilder<TInput, TOutput> {
/**
* Transform DTO into ViewModel
*
* @param dto - API Transport DTO
* @returns ViewModel
*/
build(dto: TInput): TOutput;
}

View File

@@ -0,0 +1,33 @@
/**
* Mutation Contract
*
* Purpose: Framework-agnostic write operations
*
* Rules:
* - Orchestrates services for writes
* - No HTTP/API calls directly
* - No 'use client' directive
* - No 'use server' directive
* - Must be in lib/mutations/
* - Must be named *Mutation
* - Can be called from Server Actions
* - Single responsibility: ONE operation per mutation
*
* Pattern:
* Server Action → Mutation → Service → API Client
*
* Design Principle:
* Each mutation does ONE thing. If you need multiple operations,
* create multiple mutation classes (e.g., UpdateUserStatusMutation, DeleteUserMutation).
* This follows the same pattern as Page Queries.
*/
export interface Mutation<TInput = void, TOutput = void> {
/**
* Execute the mutation
*
* @param input - Mutation input
* @returns Output (optional)
*/
execute(input: TInput): Promise<TOutput>;
}

View File

@@ -1,4 +1,4 @@
import { PageQueryResult } from "@/lib/page-queries/page-query-result/PageQueryResult";
import { PageQueryResult } from "./PageQueryResult";
/**

View File

@@ -0,0 +1,37 @@
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { Mutation } from '@/lib/contracts/mutations/Mutation';
/**
* DeleteUserMutation
*
* Framework-agnostic mutation for deleting users.
* Called from Server Actions.
*
* Input: { userId: string }
* Output: void
*
* Pattern: Server Action → Mutation → Service → API Client
*/
export class DeleteUserMutation implements Mutation<{ userId: string }, void> {
private service: AdminService;
constructor() {
// Manual DI for serverless
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
this.service = new AdminService(apiClient);
}
async execute(input: { userId: string }): Promise<void> {
await this.service.deleteUser(input.userId);
}
}

View File

@@ -0,0 +1,37 @@
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { Mutation } from '@/lib/contracts/mutations/Mutation';
/**
* UpdateUserStatusMutation
*
* Framework-agnostic mutation for updating user status.
* Called from Server Actions.
*
* Input: { userId: string; status: string }
* Output: void
*
* Pattern: Server Action → Mutation → Service → API Client
*/
export class UpdateUserStatusMutation implements Mutation<{ userId: string; status: string }, void> {
private service: AdminService;
constructor() {
// Manual DI for serverless
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
this.service = new AdminService(apiClient);
}
async execute(input: { userId: string; status: string }): Promise<void> {
await this.service.updateUserStatus(input.userId, input.status);
}
}

View File

@@ -0,0 +1,50 @@
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { AdminService } from '@/lib/services/admin/AdminService';
import { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
/**
* AdminDashboardPageQuery
*
* Server-side composition for admin dashboard page.
* Fetches dashboard statistics from API and transforms to View Data using builders.
*
* Follows Clean Architecture: DTOs never leak into application code.
*/
export class AdminDashboardPageQuery implements PageQuery<AdminDashboardViewData, void> {
async execute(): Promise<PageQueryResult<AdminDashboardViewData>> {
try {
// Create required dependencies
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: false,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const adminService = new AdminService(apiClient);
// Fetch dashboard stats (API DTO)
const apiDto = await adminService.getDashboardStats();
// Transform API DTO to View Data using builder
const viewData = AdminDashboardViewDataBuilder.build(apiDto);
return { status: 'ok', dto: viewData };
} catch (error) {
console.error('AdminDashboardPageQuery failed:', error);
if (error instanceof Error && (error.message.includes('403') || error.message.includes('401'))) {
return { status: 'notFound' };
}
return { status: 'error', errorId: 'admin_dashboard_fetch_failed' };
}
}
}

View File

@@ -0,0 +1,93 @@
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { AdminService } from '@/lib/services/admin/AdminService';
export interface AdminUsersPageDto {
users: Array<{
id: string;
email: string;
displayName: string;
roles: string[];
status: string;
isSystemAdmin: boolean;
createdAt: string;
updatedAt: string;
lastLoginAt?: string;
primaryDriverId?: string;
}>;
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* AdminUsersPageQuery
*
* Server-side composition for admin users page.
* Fetches user list from API with filtering and assembles Page DTO.
*/
export class AdminUsersPageQuery {
async execute(query: {
search?: string;
role?: string;
status?: string;
page?: number;
limit?: number;
}): Promise<PageQueryResult<AdminUsersPageDto>> {
try {
// Create required dependencies
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: false,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const adminService = new AdminService(apiClient);
// Fetch user list via service
const apiDto = await adminService.listUsers({
search: query.search,
role: query.role,
status: query.status,
page: query.page || 1,
limit: query.limit || 50,
});
// Assemble Page DTO (raw values only)
const pageDto: AdminUsersPageDto = {
users: apiDto.users.map(user => ({
id: user.id,
email: user.email,
displayName: user.displayName,
roles: user.roles,
status: user.status,
isSystemAdmin: user.isSystemAdmin,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString(),
primaryDriverId: user.primaryDriverId,
})),
total: apiDto.total,
page: apiDto.page,
limit: apiDto.limit,
totalPages: apiDto.totalPages,
};
return { status: 'ok', dto: pageDto };
} catch (error) {
console.error('AdminUsersPageQuery failed:', error);
if (error instanceof Error && (error.message.includes('403') || error.message.includes('401'))) {
return { status: 'notFound' };
}
return { status: 'error', errorId: 'admin_users_fetch_failed' };
}
}
}

View File

@@ -0,0 +1,17 @@
/**
* AdminDashboardViewData
*
* ViewData for AdminDashboardTemplate.
* Template-ready data structure with only primitives.
*/
export interface AdminDashboardViewData {
stats: {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
};
}

View File

@@ -45,6 +45,24 @@ export interface DriverSummaryData {
profileUrl: string;
}
export interface SponsorMetric {
icon: any; // React component (lucide-react icon)
label: string;
value: string | number;
color?: string;
trend?: {
value: number;
isPositive: boolean;
};
}
export interface SponsorshipSlot {
tier: 'main' | 'secondary';
available: boolean;
price: number;
benefits: string[];
}
export interface LeagueDetailViewData {
// Basic info
leagueId: string;
@@ -79,5 +97,7 @@ export interface LeagueDetailViewData {
mainSponsorPrice: number;
secondaryPrice: number;
totalImpressions: number;
metrics: SponsorMetric[];
slots: SponsorshipSlot[];
} | null;
}

View File

@@ -6,10 +6,13 @@
export interface StandingEntryData {
driverId: string;
position: number;
points: number;
wins: number;
podiums: number;
races: number;
totalPoints: number;
racesFinished: number;
racesStarted: number;
avgFinish: number | null;
penaltyPoints: number;
bonusPoints: number;
teamName?: string;
}
export interface DriverData {

View File

@@ -3,6 +3,17 @@
* Contains only raw serializable data, no methods or computed properties
*/
export interface SponsorMetric {
icon: any; // React component (lucide-react icon)
label: string;
value: string | number;
color?: string;
trend?: {
value: number;
isPositive: boolean;
};
}
export interface TeamDetailData {
id: string;
name: string;
@@ -32,9 +43,17 @@ export interface TeamMemberData {
avatarUrl: string;
}
export interface TeamTab {
id: 'overview' | 'roster' | 'standings' | 'admin';
label: string;
visible: boolean;
}
export interface TeamDetailViewData {
team: TeamDetailData;
memberships: TeamMemberData[];
currentDriverId: string;
isAdmin: boolean;
teamMetrics: SponsorMetric[];
tabs: TeamTab[];
}

View File

@@ -8,7 +8,7 @@
"start": "next start",
"lint": "eslint . --ext .ts,.tsx --max-warnings 0",
"lint:presenters": "eslint lib/presenters/*.ts",
"lint:all": "eslint .",
"lint:all": "eslint . --max-warnings 0",
"type-check": "npx tsc --noEmit",
"clean": "rm -rf .next"
},

View File

@@ -0,0 +1,136 @@
import Card from '@/components/ui/Card';
import {
Users,
Shield,
Activity,
Clock,
AlertTriangle,
RefreshCw
} from 'lucide-react';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
interface AdminDashboardTemplateProps {
viewData: AdminDashboardViewData;
onRefresh: () => void;
isLoading: boolean;
}
/**
* AdminDashboardTemplate
*
* Pure template for admin dashboard.
* Accepts ViewData only, no business logic.
*/
export function AdminDashboardTemplate({
viewData,
onRefresh,
isLoading
}: AdminDashboardTemplateProps) {
// Temporary UI fields (not yet provided by API/ViewModel)
const adminCount = viewData.stats.systemAdmins;
const systemHealth = 'Healthy';
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
<p className="text-gray-400 mt-1">System overview and statistics</p>
</div>
<button
onClick={onRefresh}
disabled={isLoading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-3xl font-bold text-white">{viewData.stats.totalUsers}</div>
</div>
<Users className="w-8 h-8 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-3xl font-bold text-white">{adminCount}</div>
</div>
<Shield className="w-8 h-8 text-purple-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active Users</div>
<div className="text-3xl font-bold text-white">{viewData.stats.activeUsers}</div>
</div>
<Activity className="w-8 h-8 text-green-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-orange-900/20 to-orange-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Recent Logins</div>
<div className="text-3xl font-bold text-white">{viewData.stats.recentLogins}</div>
</div>
<Clock className="w-8 h-8 text-orange-400" />
</div>
</Card>
</div>
{/* System Status */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">System Status</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">System Health</span>
<span className="px-2 py-1 text-xs rounded-full bg-performance-green/20 text-performance-green">
{systemHealth}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Suspended Users</span>
<span className="text-white font-medium">{viewData.stats.suspendedUsers}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Deleted Users</span>
<span className="text-white font-medium">{viewData.stats.deletedUsers}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">New Users Today</span>
<span className="text-white font-medium">{viewData.stats.newUsersToday}</span>
</div>
</div>
</Card>
{/* Quick Actions */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<a href="/admin/users" className="px-4 py-3 bg-primary-blue/20 border border-primary-blue/30 text-primary-blue rounded-lg hover:bg-primary-blue/30 transition-colors text-sm font-medium text-center">
View All Users
</a>
<a href="/admin" className="px-4 py-3 bg-purple-500/20 border border-purple-500/30 text-purple-300 rounded-lg hover:bg-purple-500/30 transition-colors text-sm font-medium text-center">
Manage Admins
</a>
<a href="/admin" className="px-4 py-3 bg-orange-500/20 border border-orange-500/30 text-orange-300 rounded-lg hover:bg-orange-500/30 transition-colors text-sm font-medium text-center">
View Audit Log
</a>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,335 @@
import Card from '@/components/ui/Card';
import StatusBadge from '@/components/ui/StatusBadge';
import {
Search,
Filter,
RefreshCw,
Users,
Shield,
Trash2,
AlertTriangle
} from 'lucide-react';
import { AdminUsersViewData } from './AdminUsersViewData';
interface AdminUsersTemplateProps {
viewData: AdminUsersViewData;
onRefresh: () => void;
onSearch: (search: string) => void;
onFilterRole: (role: string) => void;
onFilterStatus: (status: string) => void;
onClearFilters: () => void;
onUpdateStatus: (userId: string, status: string) => void;
onDeleteUser: (userId: string) => void;
search: string;
roleFilter: string;
statusFilter: string;
loading: boolean;
error: string | null;
deletingUser: string | null;
}
/**
* AdminUsersTemplate
*
* Pure template for admin users page.
* Accepts ViewData only, no business logic.
*/
export function AdminUsersTemplate({
viewData,
onRefresh,
onSearch,
onFilterRole,
onFilterStatus,
onClearFilters,
onUpdateStatus,
onDeleteUser,
search,
roleFilter,
statusFilter,
loading,
error,
deletingUser
}: AdminUsersTemplateProps) {
const toStatusBadgeProps = (
status: string,
): { status: 'success' | 'warning' | 'error' | 'neutral'; label: string } => {
switch (status) {
case 'active':
return { status: 'success', label: 'Active' };
case 'suspended':
return { status: 'warning', label: 'Suspended' };
case 'deleted':
return { status: 'error', label: 'Deleted' };
default:
return { status: 'neutral', label: status };
}
};
const getRoleBadgeClass = (role: string) => {
switch (role) {
case 'owner':
return 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
case 'admin':
return 'bg-blue-500/20 text-blue-300 border border-blue-500/30';
default:
return 'bg-gray-500/20 text-gray-300 border border-gray-500/30';
}
};
const getRoleBadgeLabel = (role: string) => {
switch (role) {
case 'owner':
return 'Owner';
case 'admin':
return 'Admin';
case 'user':
return 'User';
default:
return role;
}
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-gray-400 mt-1">Manage and monitor all system users</p>
</div>
<button
onClick={onRefresh}
disabled={loading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Error Banner */}
{error && (
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">Error</div>
<div className="text-sm opacity-90">{error}</div>
</div>
<button
onClick={() => {}}
className="text-racing-red hover:opacity-70"
>
×
</button>
</div>
)}
{/* Filters Card */}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">Filters</span>
</div>
{(search || roleFilter || statusFilter) && (
<button
onClick={onClearFilters}
className="text-xs text-primary-blue hover:text-blue-400"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue transition-colors"
/>
</div>
<select
value={roleFilter}
onChange={(e) => onFilterRole(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Roles</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<select
value={statusFilter}
onChange={(e) => onFilterStatus(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="deleted">Deleted</option>
</select>
</div>
</div>
</Card>
{/* Users Table */}
<Card>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading users...</div>
</div>
) : !viewData.users || viewData.users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<Users className="w-12 h-12 text-gray-600" />
<div className="text-gray-400">No users found</div>
<button
onClick={onClearFilters}
className="text-primary-blue hover:text-blue-400 text-sm"
>
Clear filters
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">User</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Email</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Roles</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Status</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Last Login</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody>
{viewData.users.map((user, index: number) => (
<tr
key={user.id}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors ${index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center">
<Shield className="w-4 h-4 text-primary-blue" />
</div>
<div>
<div className="font-medium text-white">{user.displayName}</div>
<div className="text-xs text-gray-500">ID: {user.id}</div>
{user.primaryDriverId && (
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div>
)}
</div>
</div>
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-300">{user.email}</div>
</td>
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1">
{user.roles.map((role: string, idx: number) => (
<span
key={idx}
className={`px-2 py-1 text-xs rounded-full font-medium ${getRoleBadgeClass(role)}`}
>
{getRoleBadgeLabel(role)}
</span>
))}
</div>
</td>
<td className="py-3 px-4">
{(() => {
const badge = toStatusBadgeProps(user.status);
return <StatusBadge status={badge.status} label={badge.label} />;
})()}
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
</div>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{user.status === 'active' && (
<button
onClick={() => onUpdateStatus(user.id, 'suspended')}
className="px-3 py-1 text-xs rounded bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30 transition-colors"
>
Suspend
</button>
)}
{user.status === 'suspended' && (
<button
onClick={() => onUpdateStatus(user.id, 'active')}
className="px-3 py-1 text-xs rounded bg-performance-green/20 text-performance-green hover:bg-performance-green/30 transition-colors"
>
Activate
</button>
)}
{user.status !== 'deleted' && (
<button
onClick={() => onDeleteUser(user.id)}
disabled={deletingUser === user.id}
className="px-3 py-1 text-xs rounded bg-racing-red/20 text-racing-red hover:bg-racing-red/30 transition-colors flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Stats Summary */}
{viewData.users.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-2xl font-bold text-white">{viewData.total}</div>
</div>
<Users className="w-6 h-6 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active</div>
<div className="text-2xl font-bold text-white">
{viewData.users.filter(u => u.status === 'active').length}
</div>
</div>
<div className="w-6 h-6 text-green-400"></div>
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-2xl font-bold text-white">
{viewData.users.filter(u => u.isSystemAdmin).length}
</div>
</div>
<Shield className="w-6 h-6 text-purple-400" />
</div>
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
/**
* AdminUsersViewData
*
* ViewData for AdminUsersTemplate.
* Template-ready data structure with only primitives.
*/
export interface AdminUsersViewData {
users: Array<{
id: string;
email: string;
displayName: string;
roles: string[];
status: string;
isSystemAdmin: boolean;
createdAt: string;
updatedAt: string;
lastLoginAt?: string;
primaryDriverId?: string;
}>;
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@@ -1,113 +1,27 @@
'use client';
import React from 'react';
import { Trophy, Medal, Search, ArrowLeft } from 'lucide-react';
import { Trophy, Search, ArrowLeft, Medal } from 'lucide-react';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import DriverRankingsFilter from '@/components/DriverRankingsFilter';
import DriverTopThreePodium from '@/components/DriverTopThreePodium';
import { DriverTopThreePodium } from '@/components/DriverTopThreePodium';
import Image from 'next/image';
// ============================================================================
// TYPES
// ============================================================================
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
interface DriverRankingsTemplateProps {
drivers: DriverLeaderboardItemViewModel[];
searchQuery: string;
selectedSkill: 'all' | SkillLevel;
sortBy: SortBy;
showFilters: boolean;
onSearchChange: (query: string) => void;
onSkillChange: (skill: 'all' | SkillLevel) => void;
onSortChange: (sort: SortBy) => void;
onToggleFilters: () => void;
onDriverClick: (id: string) => void;
onBackToLeaderboards: () => void;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
const getMedalColor = (position: number) => {
switch (position) {
case 1: return 'text-yellow-400';
case 2: return 'text-gray-300';
case 3: return 'text-amber-600';
default: return 'text-gray-500';
}
};
const getMedalBg = (position: number) => {
switch (position) {
case 1: return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40';
case 2: return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40';
case 3: return 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40';
default: return 'bg-iron-gray/50 border-charcoal-outline';
}
};
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export default function DriverRankingsTemplate({
drivers,
searchQuery,
selectedSkill,
sortBy,
showFilters,
onSearchChange,
onSkillChange,
onSortChange,
onToggleFilters,
onDriverClick,
onBackToLeaderboards,
}: DriverRankingsTemplateProps) {
// Filter drivers
const filteredDrivers = drivers.filter((driver) => {
const matchesSearch = driver.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
driver.nationality.toLowerCase().includes(searchQuery.toLowerCase());
const matchesSkill = selectedSkill === 'all' || driver.skillLevel === selectedSkill;
return matchesSearch && matchesSkill;
});
// Sort drivers
const sortedDrivers = [...filteredDrivers].sort((a, b) => {
const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY;
const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY;
switch (sortBy) {
case 'rank':
return rankA - rankB || b.rating - a.rating || a.name.localeCompare(b.name);
case 'rating':
return b.rating - a.rating;
case 'wins':
return b.wins - a.wins;
case 'podiums':
return b.podiums - a.podiums;
case 'winRate': {
const aRate = a.racesCompleted > 0 ? a.wins / a.racesCompleted : 0;
const bRate = b.racesCompleted > 0 ? b.wins / b.racesCompleted : 0;
return bRate - aRate;
}
default:
return 0;
}
});
export function DriverRankingsTemplate(props: { viewData: DriverRankingsViewData }): React.ReactElement {
const { viewData } = props;
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Header */}
<div className="mb-8">
<Button
variant="secondary"
onClick={onBackToLeaderboards}
onClick={viewData.onBackToLeaderboards}
className="flex items-center gap-2 mb-6"
>
<ArrowLeft className="w-4 h-4" />
@@ -128,18 +42,20 @@ export default function DriverRankingsTemplate({
</div>
{/* Top 3 Podium */}
{!searchQuery && sortBy === 'rank' && <DriverTopThreePodium drivers={sortedDrivers} onDriverClick={onDriverClick} />}
{viewData.podium.length > 0 && (
<DriverTopThreePodium podium={viewData.podium} onDriverClick={viewData.onDriverClick} />
)}
{/* Filters */}
<DriverRankingsFilter
searchQuery={searchQuery}
onSearchChange={onSearchChange}
selectedSkill={selectedSkill}
onSkillChange={onSkillChange}
sortBy={sortBy}
onSortChange={onSortChange}
showFilters={showFilters}
onToggleFilters={onToggleFilters}
searchQuery={viewData.searchQuery}
onSearchChange={viewData.onSearchChange}
selectedSkill={viewData.selectedSkill}
onSkillChange={viewData.onSkillChange}
sortBy={viewData.sortBy}
onSortChange={viewData.onSortChange}
showFilters={viewData.showFilters}
onToggleFilters={viewData.onToggleFilters}
/>
{/* Leaderboard Table */}
@@ -157,93 +73,85 @@ export default function DriverRankingsTemplate({
{/* Table Body */}
<div className="divide-y divide-charcoal-outline/50">
{sortedDrivers.map((driver, index) => {
const winRate = driver.racesCompleted > 0 ? ((driver.wins / driver.racesCompleted) * 100).toFixed(1) : '0.0';
const position = index + 1;
{viewData.drivers.map((driver) => (
<button
key={driver.id}
type="button"
onClick={() => viewData.onDriverClick(driver.id)}
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${driver.medalBg} ${driver.medalColor}`}>
{driver.rank <= 3 ? <Medal className="w-4 h-4" /> : driver.rank}
</div>
</div>
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Medal className="w-4 h-4" /> : position}
{/* Driver Info */}
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
{driver.nationality}
</span>
<span className="flex items-center gap-1">
{driver.skillLevel}
</span>
</div>
</div>
</div>
{/* Driver Info */}
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
{driver.nationality}
</span>
<span className="flex items-center gap-1">
{driver.skillLevel}
</span>
</div>
</div>
</div>
{/* Races */}
<div className="col-span-2 items-center justify-center hidden md:flex">
<span className="text-gray-400">{driver.racesCompleted}</span>
</div>
{/* Races */}
<div className="col-span-2 items-center justify-center hidden md:flex">
<span className="text-gray-400">{driver.racesCompleted}</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${viewData.sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}`}>
{driver.rating.toLocaleString()}
</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}`}>
{driver.rating.toLocaleString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${viewData.sortBy === 'wins' ? 'text-primary-blue' : 'text-performance-green'}`}>
{driver.wins}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-primary-blue' : 'text-performance-green'}`}>
{driver.wins}
</span>
</div>
{/* Podiums */}
<div className="col-span-1 items-center justify-center hidden lg:flex">
<span className={`font-mono font-semibold ${viewData.sortBy === 'podiums' ? 'text-primary-blue' : 'text-warning-amber'}`}>
{driver.podiums}
</span>
</div>
{/* Podiums */}
<div className="col-span-1 items-center justify-center hidden lg:flex">
<span className={`font-mono font-semibold ${sortBy === 'podiums' ? 'text-primary-blue' : 'text-warning-amber'}`}>
{driver.podiums}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-primary-blue' : 'text-white'}`}>
{winRate}%
</span>
</div>
</button>
);
})}
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${viewData.sortBy === 'winRate' ? 'text-primary-blue' : 'text-white'}`}>
{driver.winRate}%
</span>
</div>
</button>
))}
</div>
{/* Empty State */}
{sortedDrivers.length === 0 && (
{viewData.drivers.length === 0 && (
<div className="py-16 text-center">
<Search className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No drivers found</p>
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
<Button
variant="secondary"
onClick={() => {
onSearchChange('');
onSkillChange('all');
}}
onClick={viewData.onClearFilters}
className="mt-4"
>
Clear Filters

View File

@@ -1,19 +1,14 @@
'use client';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
import SponsorInsightsCard, {
MetricBuilders,
SlotTemplates,
type SponsorMetric,
} from '@/components/sponsors/SponsorInsightsCard';
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
import type { LeagueDetailPageViewModel, DriverSummary } from '@/lib/view-models/LeagueDetailPageViewModel';
import type { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import type { DriverSummaryData, LeagueDetailViewData, LeagueInfoData, LiveRaceData, SponsorInfo } from '@/lib/view-data/LeagueDetailViewData';
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
import Image from 'next/image';
import { ReactNode } from 'react';
// ============================================================================
@@ -21,45 +16,35 @@ import { ReactNode } from 'react';
// ============================================================================
interface LeagueDetailTemplateProps {
viewModel: LeagueDetailPageViewModel;
viewData: LeagueDetailViewData;
leagueId: string;
isSponsor: boolean;
membership: { role: string } | null;
currentDriverId: string | null;
onMembershipChange: () => void;
onEndRaceModalOpen: (raceId: string) => void;
onLiveRaceClick: (raceId: string) => void;
onBackToLeagues: () => void;
children?: ReactNode;
}
interface LiveRaceCardProps {
races: RaceViewModel[];
races: LiveRaceData[];
membership: { role: string } | null;
onLiveRaceClick: (raceId: string) => void;
onEndRaceModalOpen: (raceId: string) => void;
}
interface LeagueInfoCardProps {
viewModel: LeagueDetailPageViewModel;
info: LeagueInfoData;
}
interface SponsorsSectionProps {
sponsors: Array<{
id: string;
name: string;
tier: 'main' | 'secondary';
logoUrl?: string;
tagline?: string;
websiteUrl?: string;
}>;
sponsors: SponsorInfo[];
}
interface ManagementSectionProps {
ownerSummary?: DriverSummary | null;
adminSummaries: DriverSummary[];
stewardSummaries: DriverSummary[];
leagueId: string;
ownerSummary: DriverSummaryData | null;
adminSummaries: DriverSummaryData[];
stewardSummaries: DriverSummaryData[];
}
// ============================================================================
@@ -140,7 +125,7 @@ function LiveRaceCard({ races, membership, onLiveRaceClick, onEndRaceModalOpen }
// LEAGUE INFO CARD COMPONENT
// ============================================================================
function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
function LeagueInfoCard({ info }: LeagueInfoCardProps) {
return (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
@@ -148,15 +133,15 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{viewModel.memberships.length}</div>
<div className="text-xl font-bold text-white">{info.membersCount}</div>
<div className="text-xs text-gray-500">Members</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{viewModel.completedRacesCount}</div>
<div className="text-xl font-bold text-white">{info.racesCount}</div>
<div className="text-xs text-gray-500">Races</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-warning-amber">{viewModel.averageSOF ?? '—'}</div>
<div className="text-xl font-bold text-warning-amber">{info.avgSOF ?? '—'}</div>
<div className="text-xs text-gray-500">Avg SOF</div>
</div>
</div>
@@ -165,16 +150,16 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Structure</span>
<span className="text-white">Solo {viewModel.settings.maxDrivers ?? 32} max</span>
<span className="text-white">{info.structure}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Scoring</span>
<span className="text-white">{viewModel.scoringConfig?.scoringPresetName ?? 'Standard'}</span>
<span className="text-white">{info.scoring}</span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-gray-500">Created</span>
<span className="text-white">
{new Date(viewModel.createdAt).toLocaleDateString('en-US', {
{new Date(info.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric'
})}
@@ -182,12 +167,12 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
</div>
</div>
{viewModel.socialLinks && (
{(info.discordUrl || info.youtubeUrl || info.websiteUrl) && (
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<div className="flex flex-wrap gap-2">
{viewModel.socialLinks.discordUrl && (
{info.discordUrl && (
<a
href={viewModel.socialLinks.discordUrl}
href={info.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
@@ -195,9 +180,9 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
Discord
</a>
)}
{viewModel.socialLinks.youtubeUrl && (
{info.youtubeUrl && (
<a
href={viewModel.socialLinks.youtubeUrl}
href={info.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
@@ -205,9 +190,9 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
YouTube
</a>
)}
{viewModel.socialLinks.websiteUrl && (
{info.websiteUrl && (
<a
href={viewModel.socialLinks.websiteUrl}
href={info.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
@@ -244,9 +229,11 @@ function SponsorsSection({ sponsors }: SponsorsSectionProps) {
<div className="flex items-center gap-3">
{sponsor.logoUrl ? (
<div className="w-12 h-12 rounded-lg bg-white flex items-center justify-center overflow-hidden">
<img
<Image
src={sponsor.logoUrl}
alt={sponsor.name}
width={40}
height={40}
className="w-10 h-10 object-contain"
/>
</div>
@@ -291,9 +278,11 @@ function SponsorsSection({ sponsors }: SponsorsSectionProps) {
<div className="flex items-center gap-2">
{sponsor.logoUrl ? (
<div className="w-8 h-8 rounded bg-white flex items-center justify-center overflow-hidden flex-shrink-0">
<img
<Image
src={sponsor.logoUrl}
alt={sponsor.name}
width={24}
height={24}
className="w-6 h-6 object-contain"
/>
</div>
@@ -329,82 +318,78 @@ function SponsorsSection({ sponsors }: SponsorsSectionProps) {
// MANAGEMENT SECTION COMPONENT
// ============================================================================
function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries, leagueId }: ManagementSectionProps) {
function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries }: ManagementSectionProps) {
if (!ownerSummary && adminSummaries.length === 0 && stewardSummaries.length === 0) return null;
return (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
<div className="space-y-2">
{ownerSummary && (() => {
const summary = ownerSummary;
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('owner');
const meta = summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={summary.driver}
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
{ownerSummary && (
<div className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: ownerSummary.driverId,
name: ownerSummary.driverName,
avatarUrl: ownerSummary.avatarUrl,
}}
href={ownerSummary.profileUrl}
meta={ownerSummary.rating !== null
? `Rating ${ownerSummary.rating}${ownerSummary.rank ? ` • Rank ${ownerSummary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
);
})()}
<span className={`px-2 py-1 text-xs font-medium rounded border ${ownerSummary.roleBadgeClasses}`}>
{ownerSummary.roleBadgeText}
</span>
</div>
)}
{adminSummaries.map((summary) => {
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('admin');
const meta = summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div key={summary.driver.id} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={summary.driver}
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
{adminSummaries.map((summary) => (
<div key={summary.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: summary.driverId,
name: summary.driverName,
avatarUrl: summary.avatarUrl,
}}
href={summary.profileUrl}
meta={summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
);
})}
<span className={`px-2 py-1 text-xs font-medium rounded border ${summary.roleBadgeClasses}`}>
{summary.roleBadgeText}
</span>
</div>
))}
{stewardSummaries.map((summary) => {
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('steward');
const meta = summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div key={summary.driver.id} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={summary.driver}
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
{stewardSummaries.map((summary) => (
<div key={summary.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: summary.driverId,
name: summary.driverName,
avatarUrl: summary.avatarUrl,
}}
href={summary.profileUrl}
meta={summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
);
})}
<span className={`px-2 py-1 text-xs font-medium rounded border ${summary.roleBadgeClasses}`}>
{summary.roleBadgeText}
</span>
</div>
))}
</div>
</Card>
);
@@ -415,59 +400,44 @@ function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries, lea
// ============================================================================
export function LeagueDetailTemplate({
viewModel,
viewData,
leagueId,
isSponsor,
membership,
currentDriverId,
onMembershipChange,
onEndRaceModalOpen,
onLiveRaceClick,
onBackToLeagues,
children,
}: LeagueDetailTemplateProps) {
// Build metrics for SponsorInsightsCard
const leagueMetrics: SponsorMetric[] = [
MetricBuilders.views(viewModel.sponsorInsights.avgViewsPerRace, 'Avg Views/Race'),
MetricBuilders.engagement(viewModel.sponsorInsights.engagementRate),
MetricBuilders.reach(viewModel.sponsorInsights.estimatedReach),
MetricBuilders.sof(viewModel.averageSOF ?? '—'),
];
return (
<>
{/* Sponsor Insights Card - Only shown to sponsors, at top of page */}
{isSponsor && viewModel && (
{isSponsor && viewData.sponsorInsights && (
<SponsorInsightsCard
entityType="league"
entityId={leagueId}
entityName={viewModel.name}
tier={viewModel.sponsorInsights.tier}
metrics={leagueMetrics}
slots={SlotTemplates.league(
viewModel.sponsorInsights.mainSponsorAvailable,
viewModel.sponsorInsights.secondarySlotsAvailable,
viewModel.sponsorInsights.mainSponsorPrice,
viewModel.sponsorInsights.secondaryPrice
)}
trustScore={viewModel.sponsorInsights.trustScore}
discordMembers={viewModel.sponsorInsights.discordMembers}
monthlyActivity={viewModel.sponsorInsights.monthlyActivity}
entityName={viewData.name}
tier={viewData.sponsorInsights.tier}
metrics={viewData.sponsorInsights.metrics}
slots={viewData.sponsorInsights.slots}
trustScore={viewData.sponsorInsights.trustScore}
discordMembers={viewData.sponsorInsights.discordMembers}
monthlyActivity={viewData.sponsorInsights.monthlyActivity}
additionalStats={{
label: 'League Stats',
items: [
{ label: 'Total Races', value: viewModel.completedRacesCount },
{ label: 'Active Members', value: viewModel.memberships.length },
{ label: 'Total Impressions', value: viewModel.sponsorInsights.totalImpressions },
{ label: 'Total Races', value: viewData.info.racesCount },
{ label: 'Active Members', value: viewData.info.membersCount },
{ label: 'Total Impressions', value: viewData.sponsorInsights.totalImpressions },
],
}}
/>
)}
{/* Live Race Card - Prominently show running races */}
{viewModel && viewModel.runningRaces.length > 0 && (
{viewData.runningRaces.length > 0 && (
<LiveRaceCard
races={viewModel.runningRaces}
races={viewData.runningRaces}
membership={membership}
onLiveRaceClick={onLiveRaceClick}
onEndRaceModalOpen={onEndRaceModalOpen}
@@ -505,19 +475,18 @@ export function LeagueDetailTemplate({
{/* Right Sidebar - League Info */}
<div className="space-y-6">
{/* League Info - Combined */}
<LeagueInfoCard viewModel={viewModel} />
<LeagueInfoCard info={viewData.info} />
{/* Sponsors Section - Show sponsor logos */}
{viewModel.sponsors.length > 0 && (
<SponsorsSection sponsors={viewModel.sponsors} />
{viewData.sponsors.length > 0 && (
<SponsorsSection sponsors={viewData.sponsors} />
)}
{/* Management */}
<ManagementSection
ownerSummary={viewModel.ownerSummary}
adminSummaries={viewModel.adminSummaries}
stewardSummaries={viewModel.stewardSummaries}
leagueId={leagueId}
ownerSummary={viewData.ownerSummary}
adminSummaries={viewData.adminSummaries}
stewardSummaries={viewData.stewardSummaries}
/>
</div>
</div>

View File

@@ -1,23 +1,16 @@
'use client';
import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats';
import { StandingsTable } from '@/components/leagues/StandingsTable';
import Card from '@/components/ui/Card';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
// ============================================================================
// TYPES
// ============================================================================
interface LeagueStandingsTemplateProps {
standings: StandingEntryViewModel[];
drivers: DriverViewModel[];
memberships: LeagueMembership[];
leagueId: string;
currentDriverId: string | null;
isAdmin: boolean;
viewData: LeagueStandingsViewData;
onRemoveMember: (driverId: string) => void;
onUpdateRole: (driverId: string, newRole: string) => void;
loading?: boolean;
@@ -28,12 +21,7 @@ interface LeagueStandingsTemplateProps {
// ============================================================================
export function LeagueStandingsTemplate({
standings,
drivers,
memberships,
leagueId,
currentDriverId,
isAdmin,
viewData,
onRemoveMember,
onUpdateRole,
loading = false,
@@ -49,38 +37,16 @@ export function LeagueStandingsTemplate({
return (
<div className="space-y-6">
{/* Championship Stats */}
<LeagueChampionshipStats standings={standings} drivers={drivers} />
<LeagueChampionshipStats standings={viewData.standings} drivers={viewData.drivers} />
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
<StandingsTable
standings={standings.map((s) => ({
leagueId,
driverId: s.driverId,
position: s.position,
totalPoints: s.points,
racesFinished: s.races,
racesStarted: s.races,
avgFinish: null,
penaltyPoints: 0,
bonusPoints: 0,
}) satisfies {
leagueId: string;
driverId: string;
position: number;
totalPoints: number;
racesFinished: number;
racesStarted: number;
avgFinish: number | null;
penaltyPoints: number;
bonusPoints: number;
teamName?: string;
})}
drivers={drivers}
leagueId={leagueId}
memberships={memberships}
currentDriverId={currentDriverId ?? undefined}
isAdmin={isAdmin}
standings={viewData.standings}
drivers={viewData.drivers}
memberships={viewData.memberships}
currentDriverId={viewData.currentDriverId ?? undefined}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onUpdateRole={onUpdateRole}
/>

View File

@@ -1,7 +1,7 @@
'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import SponsorInsightsCard, { MetricBuilders, SlotTemplates, useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
import SponsorInsightsCard, { SlotTemplates, useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Image from 'next/image';
@@ -13,7 +13,7 @@ import TeamStandings from '@/components/teams/TeamStandings';
import StatItem from '@/components/teams/StatItem';
import { getMediaUrl } from '@/lib/utilities/media';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import type { TeamDetailViewData, TeamDetailData, TeamMemberData } from './TeamDetailViewData';
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
@@ -22,12 +22,9 @@ type Tab = 'overview' | 'roster' | 'standings' | 'admin';
// ============================================================================
export interface TeamDetailTemplateProps {
// Data props
team: TeamDetailData | null;
memberships: TeamMemberData[];
viewData: TeamDetailViewData;
activeTab: Tab;
loading: boolean;
isAdmin: boolean;
// Event handlers
onTabChange: (tab: Tab) => void;
@@ -41,12 +38,10 @@ export interface TeamDetailTemplateProps {
// MAIN TEMPLATE COMPONENT
// ============================================================================
export default function TeamDetailTemplate({
team,
memberships,
export function TeamDetailTemplate({
viewData,
activeTab,
loading,
isAdmin,
onTabChange,
onUpdate,
onRemoveMember,
@@ -65,7 +60,7 @@ export default function TeamDetailTemplate({
}
// Show not found state
if (!team) {
if (!viewData.team) {
return (
<div className="max-w-6xl mx-auto">
<Card>
@@ -87,20 +82,11 @@ export default function TeamDetailTemplate({
{ id: 'overview', label: 'Overview', visible: true },
{ id: 'roster', label: 'Roster', visible: true },
{ id: 'standings', label: 'Standings', visible: true },
{ id: 'admin', label: 'Admin', visible: isAdmin },
{ id: 'admin', label: 'Admin', visible: viewData.isAdmin },
];
const visibleTabs = tabs.filter(tab => tab.visible);
// Build sponsor insights for team using real membership and league data
const leagueCount = team.leagues?.length ?? 0;
const teamMetrics = [
MetricBuilders.members(memberships.length),
MetricBuilders.reach(memberships.length * 15),
MetricBuilders.races(leagueCount),
MetricBuilders.engagement(82),
];
return (
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
@@ -108,18 +94,18 @@ export default function TeamDetailTemplate({
items={[
{ label: 'Home', href: '/' },
{ label: 'Teams', href: '/teams' },
{ label: team.name }
{ label: viewData.team.name }
]}
/>
{/* Sponsor Insights Card - Consistent placement at top */}
{isSponsorMode && team && (
{isSponsorMode && viewData.team && (
<SponsorInsightsCard
entityType="team"
entityId={team.id}
entityName={team.name}
entityId={viewData.team.id}
entityName={viewData.team.name}
tier="standard"
metrics={teamMetrics}
metrics={viewData.teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
@@ -131,8 +117,8 @@ export default function TeamDetailTemplate({
<div className="flex items-start gap-6">
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={getMediaUrl('team-logo', team.id)}
alt={team.name}
src={getMediaUrl('team-logo', viewData.team.id)}
alt={viewData.team.name}
width={96}
height={96}
className="w-full h-full object-cover"
@@ -141,39 +127,39 @@ export default function TeamDetailTemplate({
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{team.name}</h1>
{team.tag && (
<h1 className="text-3xl font-bold text-white">{viewData.team.name}</h1>
{viewData.team.tag && (
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
[{team.tag}]
[{viewData.team.tag}]
</span>
)}
</div>
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
<p className="text-gray-300 mb-4 max-w-2xl">{viewData.team.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
{team.category && (
<span>{viewData.memberships.length} {viewData.memberships.length === 1 ? 'member' : 'members'}</span>
{viewData.team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-2 h-2 rounded-full bg-purple-400"></span>
{team.category}
{viewData.team.category}
</span>
)}
{team.createdAt && (
{viewData.team.createdAt && (
<span>
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
Founded {new Date(viewData.team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
)}
{leagueCount > 0 && (
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
<span>
Active in {leagueCount} {leagueCount === 1 ? 'league' : 'leagues'}
Active in {viewData.team.leagues.length} {viewData.team.leagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
</div>
</div>
<JoinTeamButton teamId={team.id} onUpdate={onUpdate} />
<JoinTeamButton teamId={viewData.team.id} onUpdate={onUpdate} />
</div>
</Card>
@@ -185,8 +171,8 @@ export default function TeamDetailTemplate({
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 font-medium transition-all relative
${activeTab === tab.id
? 'text-primary-blue'
${activeTab === tab.id
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
@@ -206,23 +192,23 @@ export default function TeamDetailTemplate({
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h3 className="text-xl font-semibold text-white mb-4">About</h3>
<p className="text-gray-300 leading-relaxed">{team.description}</p>
<p className="text-gray-300 leading-relaxed">{viewData.team.description}</p>
</Card>
<Card>
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
{team.category && (
<StatItem label="Category" value={team.category} color="text-purple-400" />
<StatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
{viewData.team.category && (
<StatItem label="Category" value={viewData.team.category} color="text-purple-400" />
)}
{leagueCount > 0 && (
<StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
<StatItem label="Leagues" value={viewData.team.leagues.length.toString()} color="text-green-400" />
)}
{team.createdAt && (
{viewData.team.createdAt && (
<StatItem
label="Founded"
value={new Date(team.createdAt).toLocaleDateString('en-US', {
value={new Date(viewData.team.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
@@ -244,20 +230,20 @@ export default function TeamDetailTemplate({
{activeTab === 'roster' && (
<TeamRoster
teamId={team.id}
memberships={memberships}
isAdmin={isAdmin}
teamId={viewData.team.id}
memberships={viewData.memberships}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onChangeRole={onChangeRole}
/>
)}
{activeTab === 'standings' && (
<TeamStandings teamId={team.id} leagues={team.leagues} />
<TeamStandings teamId={viewData.team.id} leagues={viewData.team.leagues} />
)}
{activeTab === 'admin' && isAdmin && (
<TeamAdmin team={team} onUpdate={onUpdate} />
{activeTab === 'admin' && viewData.isAdmin && (
<TeamAdmin team={viewData.team} onUpdate={onUpdate} />
)}
</div>
</div>

View File

@@ -1,12 +1,12 @@
'use client';
import { Users, Trophy, Target } from 'lucide-react';
import { Trophy, Users } from 'lucide-react';
import Link from 'next/link';
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import type { TeamsViewData, TeamSummaryData } from './view-data/TeamsViewData';
import type { TeamSummaryData, TeamsViewData } from '../lib/view-data/TeamsViewData';
interface TeamsTemplateProps extends TeamsViewData {
searchQuery?: string;
@@ -20,7 +20,7 @@ interface TeamsTemplateProps extends TeamsViewData {
onSkillLevelClick?: (level: string) => void;
}
export function TeamsTemplate({ teams, searchQuery, onSearchChange, onShowCreateForm, onTeamClick }: TeamsTemplateProps) {
export function TeamsTemplate({ teams }: TeamsTemplateProps) {
return (
<main className="min-h-screen bg-deep-graphite py-8">
<div className="max-w-7xl mx-auto px-6">

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
/**
* Guardrail violation representation
*/
export class GuardrailViolation {
constructor(
public readonly ruleName: string,
public readonly filePath: string,
public readonly lineNumber: number,
public readonly description: string,
) {}
toString(): string {
return `${this.filePath}:${this.lineNumber} - ${this.ruleName}: ${this.description}`;
}
toJSON(): object {
return {
rule: this.ruleName,
file: this.filePath,
line: this.lineNumber,
description: this.description,
};
}
}

View File

@@ -1,88 +0,0 @@
/**
* Allowlist for architecture guardrail violations
*
* This file contains violations that currently exist in the codebase.
* In future slices, these should be shrunk to zero.
*
* Format: Each rule has an array of file paths that are allowed to violate it.
*/
export interface GuardrailAllowlist {
[ruleName: string]: string[];
}
export const ALLOWED_VIOLATIONS: GuardrailAllowlist = {
// Rule 1: ContainerManager usage in server page queries
'no-container-manager-in-server': [],
// Rule 2: PageDataFetcher.fetch() usage in server page queries
'no-page-data-fetcher-fetch-in-server': [],
// Rule 3: ViewModels imported in forbidden paths
'no-view-models-in-server': [],
// Rule 4: Templates importing view-models or display-objects
'no-view-models-in-templates': [],
// Rule 5: Intl.* or toLocale* in presentation paths
'no-intl-in-presentation': [],
// Rule 6: Client-side fetch with write methods
'no-client-write-fetch': [
'apps/website/app/sponsor/signup/page.tsx',
],
// Rule 7: *Template.tsx files under app/
'no-templates-in-app': [],
// Rule 8: 'as any' usage - ZERO TOLERANCE
// Hard fail - no allowlist entries allowed
'no-as-any': [],
// New Rule 1: RSC boundary - additional checks
'no-presenters-in-server': [],
'no-sorting-filtering-in-server': [],
'no-display-objects-in-server': [],
'no-unsafe-services-in-server': [],
'no-di-in-server': [],
'no-local-helpers-in-server': [],
'no-object-construction-in-server': [],
'no-container-manager-calls-in-server': [],
// New Rule 2: Template purity - additional checks
'no-state-hooks-in-templates': [],
'no-computations-in-templates': [],
'no-restricted-imports-in-templates': [],
'no-invalid-template-signature': [],
'no-template-helper-exports': [],
'invalid-template-filename': [],
// New Rule 3: Display Object guardrails
'no-io-in-display-objects': [],
'no-non-class-display-exports': [],
// New Rule 4: Page Query guardrails
'no-null-returns-in-page-queries': [],
'invalid-page-query-filename': [],
// New Rule 5: Services guardrails
'no-service-state': [],
'no-blockers-in-services': [],
'no-dto-variable-name': [],
// New Rule 6: Client-only guardrails
'no-use-client-directive': [],
'no-viewmodel-imports-from-server': [],
'no-http-in-presenters': [],
// New Rule 7: Write boundary guardrails
'no-server-action-imports-from-client': [],
'no-server-action-viewmodel-returns': [],
// New Rule 10: Generated DTO isolation
'no-generated-dto-in-ui': [],
'no-types-in-templates': [],
// New Rule 11: Filename rules
'invalid-app-filename': [],
};

View File

@@ -1,180 +0,0 @@
import { describe, it, expect } from 'vitest';
import { ArchitectureGuardrails } from './ArchitectureGuardrails';
/**
* Architecture Guardrail Tests
*
* These tests enforce the architectural contract for the website.
* They use an allowlist to permit existing violations while preventing new ones.
*
* The goal is to shrink the allowlist slice-by-slice until zero violations remain.
*/
describe('Architecture Guardrails', () => {
const guardrails = new ArchitectureGuardrails();
it('should detect all violations in the codebase', () => {
const allViolations = guardrails.scan();
// This test documents the current state
// It will always pass but shows what violations exist
console.log(`\n📊 Total violations found: ${allViolations.length}`);
if (allViolations.length > 0) {
console.log('\n📋 Violations by rule:');
const byRule = allViolations.reduce((acc, v) => {
acc[v.ruleName] = (acc[v.ruleName] || 0) + 1;
return acc;
}, {} as Record<string, number>);
Object.entries(byRule).forEach(([rule, count]) => {
console.log(` - ${rule}: ${count}`);
});
}
// We expect violations to exist initially
expect(allViolations.length).toBeGreaterThanOrEqual(0);
});
it('should have no violations after filtering by allowlist', () => {
const filteredViolations = guardrails.getFilteredViolations();
console.log(`\n🔍 Filtered violations (after allowlist): ${filteredViolations.length}`);
if (filteredViolations.length > 0) {
console.log('\n❌ New violations not in allowlist:');
filteredViolations.forEach(v => {
console.log(` - ${v.toString()}`);
});
}
// This is the main assertion - no new violations allowed
expect(filteredViolations.length).toBe(0);
});
it('should not have stale allowlist entries', () => {
const staleEntries = guardrails.findStaleAllowlistEntries();
console.log(`\n🧹 Stale allowlist entries: ${staleEntries.length}`);
if (staleEntries.length > 0) {
console.log('\n⚠ These allowlist entries no longer match any violations:');
staleEntries.forEach(entry => {
console.log(` - ${entry}`);
});
console.log('\n💡 Consider removing them from allowed-violations.ts');
}
// Stale entries should be removed to keep allowlist clean
expect(staleEntries.length).toBe(0);
});
it('should enforce: no ContainerManager in server page queries', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-container-manager-in-server'
);
expect(violations.length).toBe(0);
});
it('should enforce: no PageDataFetcher.fetch() in server page queries', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-page-data-fetcher-fetch-in-server'
);
expect(violations.length).toBe(0);
});
it('should enforce: no view-models imports in server code', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-view-models-in-server'
);
expect(violations.length).toBe(0);
});
it('should enforce: no view-models/display-objects in templates', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-view-models-in-templates'
);
expect(violations.length).toBe(0);
});
it('should enforce: no Intl.* or toLocale* in presentation paths', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-intl-in-presentation'
);
expect(violations.length).toBe(0);
});
it('should enforce: no client-side write fetch', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-client-write-fetch'
);
expect(violations.length).toBe(0);
});
it('should enforce: no *Template.tsx under app/', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-templates-in-app'
);
expect(violations.length).toBe(0);
});
it('should enforce: no hooks directory in apps/website/', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-hooks-directory'
);
expect(violations.length).toBe(0);
});
it('should enforce: no as any usage', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-as-any'
);
expect(violations.length).toBe(0);
});
// NEW: Contract enforcement tests
it('should enforce: PageQuery classes must implement PageQuery contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'pagequery-must-implement-contract' ||
v.ruleName === 'pagequery-must-have-execute' ||
v.ruleName === 'pagequery-execute-return-type'
);
expect(violations.length).toBe(0);
});
it('should enforce: Presenter classes must implement Presenter contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'presenter-must-implement-contract' ||
v.ruleName === 'presenter-must-have-present' ||
v.ruleName === 'presenter-must-be-client'
);
expect(violations.length).toBe(0);
});
it('should enforce: ViewModel classes must extend ViewModel contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'viewmodel-must-extend-contract' ||
v.ruleName === 'viewmodel-must-be-client'
);
expect(violations.length).toBe(0);
});
it('should enforce: DisplayObject files must export only classes', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-non-class-display-exports'
);
expect(violations.length).toBe(0);
});
});

View File

@@ -0,0 +1,207 @@
# Builders (Strict)
This document defines the **Builder** pattern for `apps/website`.
Builders exist to transform data between presentation model types.
## 1) Definition
A **Builder** is a deterministic, side-effect free transformation between website presentation models.
There are two types of builders:
### 1.1 ViewModel Builders
Transform API Transport DTOs into ViewModels.
**Purpose**: Prepare raw API data for client-side state management.
**Location**: `apps/website/lib/builders/view-models/**`
**Pattern**:
```typescript
export class AdminViewModelBuilder {
static build(dto: UserDto): AdminUserViewModel {
return new AdminUserViewModel(dto);
}
}
```
### 1.2 ViewData Builders
Transform API DTOs directly into ViewData for templates.
**Purpose**: Prepare API data for server-side rendering without ViewModels.
**Location**: `apps/website/lib/builders/view-data/**`
**Pattern**:
```typescript
export class LeagueViewDataBuilder {
static build(apiDto: LeagueApiDto): LeagueDetailViewData {
return {
leagueId: apiDto.id,
name: apiDto.name,
// ... more fields
};
}
}
```
## 2) Non-negotiable rules
### ViewModel Builders
1. MUST be deterministic
2. MUST be side-effect free
3. MUST NOT perform HTTP
4. MUST NOT call API clients
5. MUST NOT access cookies/headers
6. Input: API Transport DTO
7. Output: ViewModel
8. MUST live in `lib/builders/view-models/**`
### ViewData Builders
1. MUST be deterministic
2. MUST be side-effect free
3. MUST NOT perform HTTP
4. MUST NOT call API clients
5. MUST NOT access cookies/headers
6. Input: API DTO
7. Output: ViewData
8. MUST live in `lib/builders/view-data/**`
## 3) Why two builder types?
**ViewModel Builders** (API → Client State):
- Bridge the API boundary
- Convert transport types to client classes
- Add client-only fields if needed
- Run in client code
**ViewData Builders** (API → Render Data):
- Bridge the presentation boundary
- Transform API data directly for templates
- Format values for display
- Run in server code (RSC)
## 4) Relationship to other patterns
```
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
(for client components)
API Transport DTO
ViewData Builder (lib/builders/view-data/)
ViewData (lib/templates/)
Template (lib/templates/)
```
## 5) Naming convention
**ViewModel Builders**: `*ViewModelBuilder`
- `AdminViewModelBuilder`
- `RaceViewModelBuilder`
**ViewData Builders**: `*ViewDataBuilder`
- `LeagueViewDataBuilder`
- `RaceViewDataBuilder`
## 6) File structure
```
lib/
builders/
view-models/
AdminViewModelBuilder.ts
RaceViewModelBuilder.ts
index.ts
view-data/
LeagueViewDataBuilder.ts
RaceViewDataBuilder.ts
index.ts
```
## 7) Usage examples
### ViewModel Builder (Client Component)
```typescript
'use client';
import { AdminViewModelBuilder } from '@/lib/builders/view-models/AdminViewModelBuilder';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
export function AdminPage() {
const [users, setUsers] = useState<AdminUserViewModel[]>([]);
useEffect(() => {
const apiClient = new AdminApiClient();
const dto = await apiClient.getUsers();
const viewModels = dto.map(d => AdminViewModelBuilder.build(d));
setUsers(viewModels);
}, []);
// ... render with viewModels
}
```
### ViewData Builder (Server Component)
```typescript
import { LeagueViewDataBuilder } from '@/lib/builders/view-data/LeagueViewDataBuilder';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
export default async function LeagueDetailPage({ params }) {
const apiDto = await LeagueDetailPageQuery.execute(params.id);
const viewData = LeagueViewDataBuilder.build(apiDto);
return <LeagueDetailTemplate viewData={viewData} />;
}
```
## 8) Common mistakes
**Wrong**: Using "Presenter" for DTO → ViewModel
```typescript
// DON'T
export class AdminPresenter {
static createViewModel(dto: UserDto): AdminUserViewModel { ... }
}
```
**Correct**: Use ViewModelBuilder
```typescript
export class AdminViewModelBuilder {
static build(dto: UserDto): AdminUserViewModel { ... }
}
```
**Wrong**: Using "Transformer" for ViewModel → ViewData
```typescript
// DON'T
export class RaceResultsDataTransformer {
static transform(...): TransformedData { ... }
}
```
**Correct**: Use ViewDataBuilder
```typescript
export class RaceResultsViewDataBuilder {
static build(...): RaceResultsViewData { ... }
}
```
## 9) Enforcement
These rules are enforced by ESLint:
- `gridpilot-rules/view-model-builder-contract`
- `gridpilot-rules/view-data-builder-contract`
- `gridpilot-rules/filename-view-model-builder-match`
- `gridpilot-rules/filename-view-data-builder-match`
See [`docs/architecture/website/WEBSITE_GUARDRAILS.md`](WEBSITE_GUARDRAILS.md) for details.

View File

@@ -0,0 +1,151 @@
# Mutations (Strict)
This document defines the **Mutation** pattern for `apps/website`.
Mutations exist to provide framework-agnostic write operations.
## 1) Definition
A **Mutation** is a framework-agnostic operation that orchestrates writes.
Mutations are the write equivalent of PageQueries.
## 2) Relationship to Next.js Server Actions
**Server Actions are the entry point**, but they should be thin wrappers:
```typescript
// app/admin/actions.ts (Next.js framework code)
'use server';
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string): Promise<void> {
try {
const mutation = new AdminUserMutation();
await mutation.updateUserStatus(userId, status);
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
## 3) Mutation Structure
```typescript
// lib/mutations/admin/AdminUserMutation.ts
export class AdminUserMutation {
private service: AdminService;
constructor() {
// Manual DI for serverless
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
this.service = new AdminService(apiClient);
}
async updateUserStatus(userId: string, status: string): Promise<void> {
await this.service.updateUserStatus(userId, status);
}
async deleteUser(userId: string): Promise<void> {
await this.service.deleteUser(userId);
}
}
```
## 4) Why This Pattern?
**Benefits:**
1. **Framework independence** - Mutations can be tested without Next.js
2. **Consistent pattern** - Mirrors PageQueries for reads/writes
3. **Easy migration** - Can switch frameworks without rewriting business logic
4. **Testable** - Can unit test mutations in isolation
5. **Reusable** - Can be called from other contexts (cron jobs, etc.)
## 5) Naming Convention
- Mutations: `*Mutation.ts`
- Server Actions: `actions.ts` (thin wrappers)
## 6) File Structure
```
lib/
mutations/
admin/
AdminUserMutation.ts
AdminLeagueMutation.ts
league/
LeagueJoinMutation.ts
team/
TeamUpdateMutation.ts
```
## 7) Non-negotiable Rules
1. **Server Actions are thin wrappers** - They only handle framework concerns (revalidation, redirects)
2. **Mutations handle infrastructure** - They create services, handle errors
3. **Services handle business logic** - They orchestrate API calls
4. **Mutations are framework-agnostic** - No Next.js imports except in tests
5. **Mutations must be deterministic** - Same inputs = same outputs
## 8) Comparison with PageQueries
| Aspect | PageQuery | Mutation |
|--------|-----------|----------|
| Purpose | Read data | Write data |
| Location | `lib/page-queries/` | `lib/mutations/` |
| Framework | Called from RSC | Called from Server Actions |
| Infrastructure | Manual DI | Manual DI |
| Returns | Page DTO | void or result |
| Revalidation | N/A | Server Action handles it |
## 9) Example Flow
**Read:**
```
RSC page.tsx
PageQuery.execute()
Service
API Client
Page DTO
```
**Write:**
```
Client Component
Server Action
Mutation.execute()
Service
API Client
Revalidation
```
## 10) Enforcement
ESLint rules should enforce:
- Server Actions must call Mutations (not Services directly)
- Mutations must not import Next.js (except in tests)
- Mutations must use services
See `docs/architecture/website/WEBSITE_GUARDRAILS.md` for details.

View File

@@ -1,56 +1,40 @@
# Presenters (Strict)
# Builders (Deprecated)
This document defines the **Presenter** boundary for `apps/website`.
**This document is deprecated.** See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md) for the current pattern.
Presenters exist to prevent responsibility drift into:
## Summary of changes
- server routes
- Page Queries
- Templates
The architecture has been updated to use **Builders** instead of **Presenters**:
## 1) Definition
### Old pattern (deprecated)
- `lib/presenters/` - All transformations
- `lib/view-models/` - ViewModels + some presenters
A **Presenter** is a deterministic, side-effect free transformation between website presentation models.
### New pattern (current)
- `lib/builders/view-models/` - DTO → ViewModel
- `lib/builders/view-data/` - ViewModel → ViewData
- `lib/view-models/` - ViewModels only
Allowed transformations:
### Why the change?
- Page DTO → ViewData
- Page DTO → ViewModel
- ViewModel → ViewData
The old pattern had **three anti-patterns**:
## 2) Non-negotiable rules
1. **Inconsistent naming** - Same concept had 3 names (Presenter, Transformer, ViewModelPresenter)
2. **Inconsistent location** - Presenters lived in both `lib/presenters/` and `lib/view-models/`
3. **Confusing semantics** - "Presenter" implies presenting to client, but some presenters prepared data for server templates
1. Presenters MUST be deterministic.
2. Presenters MUST be side-effect free.
3. Presenters MUST NOT perform HTTP.
4. Presenters MUST NOT call API clients.
5. Presenters MUST NOT access cookies/headers.
6. Presenters MAY use Display Objects.
7. Presenters MUST NOT import Templates.
### What changed?
## 3) Where Presenters run
**ViewModel Builders** (DTO → ViewModel):
- Location: `lib/builders/view-models/`
- Naming: `*ViewModelBuilder`
- Example: `AdminViewModelBuilder.build(dto)`
Presenters run in **client code only**.
**ViewData Builders** (ViewModel → ViewData):
- Location: `lib/builders/view-data/`
- Naming: `*ViewDataBuilder`
- Example: `LeagueViewDataBuilder.build(viewModel, id)`
Presenters MUST be defined in `'use client'` modules.
This makes the architecture **self-documenting** and **clean**.
If a computation affects routing decisions (redirect, notFound), it belongs in a Page Query or server route composition, not in a Presenter.
## 4) Relationship to Display Objects
Display Objects implement reusable formatting/mapping.
Rules:
- Presenters may orchestrate Display Objects.
- Display Object instances MUST NOT appear in ViewData.
See [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1) and [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:1).
## 5) Canonical placement in this repo (strict)
Presenters MUST live colocated with ViewModels under:
- `apps/website/lib/view-models/**`
Reason: this repo already treats `apps/website/lib/view-models/**` as the client-only presentation module boundary.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md) for full details.

View File

@@ -0,0 +1,231 @@
# Website Services Architecture
This document defines the role and responsibilities of services in the website layer (`apps/website/lib/services/`).
## Overview
Website services are **frontend orchestration services**. They bridge the gap between server-side composition (PageQueries, Server Actions) and API infrastructure.
## Purpose
Website services answer: **"How does the website orchestrate API calls and handle infrastructure?"**
## Responsibilities
### ✅ Services MAY:
- Call API clients
- Orchestrate multiple API calls
- Handle infrastructure concerns (logging, error reporting, retries)
- Transform API DTOs to Page DTOs (if orchestration is needed)
- Cache responses (in-memory, request-scoped)
- Handle recoverable errors
### ❌ Services MUST NOT:
- Contain business rules (that's for core use cases)
- Create ViewModels (ViewModels are client-only)
- Import from `lib/view-models/` or `templates/`
- Perform UI rendering logic
- Store state across requests
## Placement
```
apps/website/lib/services/
```
## Pattern
### Service Definition
```typescript
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import type { UserDto } from '@/lib/api/admin/AdminApiClient';
export class AdminService {
constructor(private readonly apiClient: AdminApiClient) {}
async updateUserStatus(userId: string, status: string): Promise<UserDto> {
return this.apiClient.updateUserStatus(userId, status);
}
}
```
### Usage in PageQueries (Reads)
```typescript
// apps/website/lib/page-queries/AdminDashboardPageQuery.ts
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
export class AdminDashboardPageQuery {
async execute(): Promise<PageQueryResult<AdminDashboardPageDto>> {
// Create infrastructure
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {...});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
// Use service
const stats = await service.getDashboardStats();
// Transform to Page DTO
return { status: 'ok', dto: transformToPageDto(stats) };
}
}
```
### Usage in Server Actions (Writes)
```typescript
// apps/website/app/admin/actions.ts
'use server';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string): Promise<void> {
try {
// Create infrastructure
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
// Use service (NOT API client directly)
await service.updateUserStatus(userId, status);
// Revalidate
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
## Infrastructure Concerns
**Where should logging/error reporting live?**
In the current architecture, **server actions and PageQueries create infrastructure**. This is acceptable because:
1. Next.js serverless functions are stateless
2. Each request needs fresh infrastructure
3. Manual DI is clearer than magic containers
**Key principle**: Services orchestrate, they don't create infrastructure.
## Dependency Chain
```
Server Action / PageQuery
↓ (creates infrastructure)
Service
↓ (orchestrates)
API Client
↓ (makes HTTP calls)
API
```
## Naming
- Service classes: `*Service`
- Service methods: Return DTOs (not ViewModels)
- Variable names: `apiDto`, `pageDto` (never just `dto`)
## Comparison with Other Layers
| Layer | Purpose | Example |
|-------|---------|---------|
| **Website Service** | Orchestrate API calls | `AdminService` |
| **API Client** | HTTP infrastructure | `AdminApiClient` |
| **Core Use Case** | Business rules | `CreateLeagueUseCase` |
| **Domain Service** | Cross-entity logic | `StrengthOfFieldCalculator` |
## Anti-Patterns
**Wrong**: Service creates ViewModels
```typescript
// WRONG
class AdminService {
async getUser(userId: string): Promise<UserViewModel> {
const dto = await this.apiClient.getUser(userId);
return new UserViewModel(dto); // ❌ ViewModels are client-only
}
}
```
**Correct**: Service returns DTOs
```typescript
// CORRECT
class AdminService {
async getUser(userId: string): Promise<UserDto> {
return this.apiClient.getUser(userId); // ✅ DTOs are fine
}
}
```
**Wrong**: Service contains business logic
```typescript
// WRONG
class AdminService {
async canDeleteUser(userId: string): Promise<boolean> {
const user = await this.apiClient.getUser(userId);
return user.role !== 'admin'; // ❌ Business rule belongs in core
}
}
```
**Correct**: Service orchestrates
```typescript
// CORRECT
class AdminService {
async getUser(userId: string): Promise<UserDto> {
return this.apiClient.getUser(userId);
}
}
// Business logic in core use case or page query
```
**Wrong**: Server action calls API client directly
```typescript
// WRONG
'use server';
export async function updateUserStatus(userId: string, status: string) {
const apiClient = new AdminApiClient(...);
await apiClient.updateUserStatus(userId, status); // ❌ Should use service
}
```
**Correct**: Server action uses service
```typescript
// CORRECT
'use server';
export async function updateUserStatus(userId: string, status: string) {
const apiClient = new AdminApiClient(...);
const service = new AdminService(apiClient);
await service.updateUserStatus(userId, status); // ✅ Uses service
}
```
## Summary
Website services are **thin orchestration wrappers** around API clients. They handle infrastructure concerns so that PageQueries and Server Actions can focus on composition and validation.
**Key principles**:
1. Services orchestrate API calls
2. Server actions/PageQueries create infrastructure
3. Services don't create ViewModels
4. Services don't contain business rules
5. **Server actions MUST use services, not API clients directly**

View File

@@ -22,17 +22,34 @@ ViewData is not:
## 3) Construction rules
ViewData MUST be created in client code:
ViewData is created by **ViewData Builders**:
1) Initial SSR-safe render: `ViewData = fromDTO(PageDTO)`
2) Post-hydration render: `ViewData = fromViewModel(ViewModel)`
### Server Components (RSC)
```typescript
const apiDto = await PageQuery.execute();
const viewData = ViewDataBuilder.build(apiDto);
return <Template viewData={viewData} />;
```
These transformations are Presenters.
See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1).
### Client Components
```typescript
'use client';
const [viewModel, setViewModel] = useState<ViewModel | null>(null);
useEffect(() => {
const apiDto = await apiClient.get();
const vm = ViewModelBuilder.build(apiDto);
setViewModel(vm);
}, []);
// Template receives ViewModel, not ViewData
return viewModel ? <Template viewModel={viewModel} /> : null;
```
Templates MUST NOT compute derived values.
Presenters MUST NOT call the API.
ViewData Builders MUST NOT call the API.
## 4) Determinism rules
@@ -46,7 +63,7 @@ Forbidden anywhere in formatting code paths:
Reason: SSR and browser outputs can differ.
Localization MUST NOT depend on runtime locale APIs.
If localized strings are required, they MUST be provided as deterministic inputs (for example via API-provided labels or a deterministic code-to-label map) and passed through Presenters into ViewData.
If localized strings are required, they MUST be provided as deterministic inputs (for example via API-provided labels or a deterministic code-to-label map) and passed through ViewData Builders into ViewData.
## 5) Relationship to Display Objects

View File

@@ -51,39 +51,20 @@ Canonical placement in this repo:
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
### 3.2 Page DTO
### 3.2 API Transport DTO
Definition: the website-owned, server-to-client payload for a route.
Definition: the shape returned by the backend API over HTTP.
Rules:
- JSON-serializable only.
- Contains **raw** values only (IDs, ISO strings, numbers, codes).
- MUST NOT contain class instances.
- Created by Page Queries.
- Passed from server routes into client code.
- API Transport DTOs MUST be contained inside infrastructure.
- API Transport DTOs MUST NOT be imported by Templates.
Canonical placement in this repo:
- `apps/website/lib/page-queries/**` (composition and Page DTO construction)
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
### 3.3 ViewModel
Definition: the client-only, UI-owned class representing fully prepared UI state.
Rules:
- Instantiated only in `'use client'` modules.
- Never serialized.
- MUST NOT be passed into Templates.
See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1).
Canonical placement in this repo:
- `apps/website/lib/view-models/**`
### 3.4 ViewData
### 3.3 ViewData
Definition: the only allowed input type for Templates.
@@ -99,17 +80,29 @@ Canonical placement in this repo:
- `apps/website/templates/**` (Templates that accept ViewData only)
## 4) Presentation helpers (strict)
### 3.3 ViewModel
### 4.1 Presenter
Definition: the client-only, UI-owned class representing fully prepared UI state.
Definition: a deterministic, side-effect free transformation.
Rules:
Presenters map between website presentation models:
- Instantiated only in `'use client'` modules.
- Never serialized.
- Used for client components that need state management.
- Page DTO → ViewData
- Page DTO → ViewModel
- ViewModel → ViewData
See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1).
Canonical placement in this repo:
- `apps/website/lib/view-models/**`
## 4) Data transformation helpers (strict)
### 4.1 ViewModel Builder
Definition: transforms API Transport DTOs into ViewModels.
Purpose: prepare raw API data for client-side state management.
Rules:
@@ -117,15 +110,37 @@ Rules:
- MUST be side-effect free.
- MUST NOT call HTTP.
- MUST NOT call the API.
- MAY use Display Objects.
- Input: API Transport DTO
- Output: ViewModel
See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1).
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
Canonical placement in this repo:
- `apps/website/lib/presenters/**`
- `apps/website/lib/builders/view-models/**`
### 4.2 Display Object
### 4.2 ViewData Builder
Definition: transforms API DTOs directly into ViewData for templates.
Purpose: prepare API data for server-side rendering.
Rules:
- MUST be deterministic.
- MUST be side-effect free.
- MUST NOT call HTTP.
- MUST NOT call the API.
- Input: API Transport DTO
- Output: ViewData
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
Canonical placement in this repo:
- `apps/website/lib/builders/view-data/**`
### 4.3 Display Object
Definition: deterministic, reusable, UI-only formatting/mapping logic.
@@ -144,28 +159,40 @@ Canonical placement in this repo:
## 5) Read flow (strict)
### Server Components (RSC)
```text
RSC page.tsx
PageQuery (server)
PageQuery
API service / API client (infra)
API client (infra)
API Transport DTO
Page DTO
Presenter (client)
ViewModel (optional, client)
Presenter (client)
ViewData Builder (lib/builders/view-data/)
ViewData
Template
```
### Client Components
```text
Client Component
API client (useEffect)
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
Client State (useState)
Template
```
## 6) Write flow (strict)
All writes MUST enter through **Next.js Server Actions**.
@@ -179,9 +206,68 @@ Allowed:
- client submits intent (FormData, button action)
- server action performs UX validation
- server action calls the API
- **server action calls a service** (not API clients directly)
- service orchestrates API calls and business logic
See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1).
**Server Actions must use Services:**
```typescript
// ❌ WRONG - Direct API client usage
'use server';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
export async function updateUserStatus(userId: string, status: string) {
const apiClient = new AdminApiClient(...);
await apiClient.updateUserStatus(userId, status); // ❌ Should use service
}
// ✅ CORRECT - Service usage
'use server';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string) {
try {
// Create infrastructure
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
// Use service
await service.updateUserStatus(userId, status);
// Revalidate
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
**Pattern**:
1. Server action creates infrastructure (logger, errorReporter, apiClient)
2. Server action creates service with infrastructure
3. Server action calls service method
4. Server action handles revalidation and returns
**Rationale**:
- Services orchestrate API calls (can grow to multiple calls)
- Keeps server actions consistent with PageQueries
- Makes infrastructure explicit and testable
- Services can add caching, retries, transformations
See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1) and [`SERVICES.md`](docs/architecture/website/SERVICES.md:1).
## 7) Authorization (strict)
@@ -245,9 +331,12 @@ See [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:1).
1. The API is the brain.
2. The website is a terminal.
3. API Transport DTOs never reach Templates.
4. ViewModels never go to the API.
5. Templates accept ViewData only.
6. Page Queries do not format; they only compose.
7. Presenters are pure and deterministic.
8. Server Actions are the only write entry point.
9. Authorization always belongs to the API.
4. Templates accept ViewData only.
5. Page Queries do not format; they only compose.
6. ViewData Builders transform API DTO → ViewData (RSC).
7. ViewModel Builders transform API DTO → ViewModel (Client).
8. Builders are pure and deterministic.
9. Server Actions are the only write entry point.
10. Server Actions must use Mutations (not Services directly).
11. Mutations orchestrate Services for writes.
12. Authorization always belongs to the API.

View File

@@ -0,0 +1,109 @@
# Write Flow Update (Mutation Pattern)
This document updates the write flow section of WEBSITE_CONTRACT.md to use the Mutation pattern.
## Write Flow (Strict)
All writes MUST enter through **Next.js Server Actions**.
### Forbidden
- client components performing write HTTP requests
- client components calling API clients for mutations
### Allowed
- client submits intent (FormData, button action)
- server action performs UX validation
- **server action calls a Mutation** (not Services directly)
- Mutation creates infrastructure and calls Service
- Service orchestrates API calls and business logic
### Server Actions must use Mutations
```typescript
// ❌ WRONG - Direct service usage
'use server';
import { AdminService } from '@/lib/services/admin/AdminService';
export async function updateUserStatus(userId: string, status: string) {
const service = new AdminService(...);
await service.updateUserStatus(userId, status); // ❌ Should use mutation
}
```
```typescript
// ✅ CORRECT - Mutation usage
'use server';
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string) {
try {
const mutation = new AdminUserMutation();
await mutation.updateUserStatus(userId, status);
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
### Mutation Pattern
```typescript
// lib/mutations/admin/AdminUserMutation.ts
export class AdminUserMutation {
private service: AdminService;
constructor() {
// Manual DI for serverless
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
this.service = new AdminService(apiClient);
}
async updateUserStatus(userId: string, status: string): Promise<void> {
await this.service.updateUserStatus(userId, status);
}
}
```
### Flow
1. **Server Action** (thin wrapper) - handles framework concerns (revalidation, redirects)
2. **Mutation** (framework-agnostic) - creates infrastructure, calls service
3. **Service** (business logic) - orchestrates API calls
4. **API Client** (infrastructure) - makes HTTP requests
### Rationale
- **Framework independence**: Mutations can be tested without Next.js
- **Consistency**: Mirrors PageQuery pattern for reads/writes
- **Migration ease**: Can switch frameworks without rewriting business logic
- **Testability**: Can unit test mutations in isolation
- **Reusability**: Can be called from other contexts (cron jobs, etc.)
### Comparison with PageQueries
| Aspect | PageQuery | Mutation |
|--------|-----------|----------|
| Purpose | Read data | Write data |
| Location | `lib/page-queries/` | `lib/mutations/` |
| Framework | Called from RSC | Called from Server Actions |
| Infrastructure | Manual DI | Manual DI |
| Returns | Page DTO | void or result |
| Revalidation | N/A | Server Action handles it |
### See Also
- [`MUTATIONS.md`](MUTATIONS.md) - Full mutation pattern documentation
- [`SERVICES.md`](SERVICES.md) - Service layer documentation
- [`WEBSITE_CONTRACT.md`](WEBSITE_CONTRACT.md) - Main contract

View File

@@ -16,6 +16,7 @@ It renders truth from the API and forwards user intent to the API.
## 2) Read flow
### Server Components (RSC)
```text
RSC page.tsx
@@ -25,19 +26,30 @@ API client (infra)
API Transport DTO
Page DTO
Presenter (client)
ViewModel (optional)
Presenter (client)
ViewData Builder (lib/builders/view-data/)
ViewData
Template
```
### Client Components
```text
Client Component
API client (useEffect)
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
Client State (useState)
Template
```
## 3) Write flow
All writes enter through **Server Actions**.
@@ -60,6 +72,8 @@ RSC reload
1. Templates accept ViewData only.
2. Page Queries do not format.
3. Presenters do not call the API.
4. Client state is UI-only.
3. ViewData Builders transform API DTO → ViewData (RSC).
4. ViewModel Builders transform API DTO → ViewModel (Client).
5. Builders do not call the API.
6. Client state is UI-only.

View File

@@ -35,16 +35,19 @@ Canonical folders (existing in this repo):
```text
apps/website/lib/
api/  API clients
infrastructure/  technical concerns
services/  UI orchestration (read-only and write orchestration)
page-queries/  server composition
types/  API transport DTOs
view-models/  client-only classes
display-objects/  deterministic formatting helpers
command-models/  transient form models
blockers/  UX-only prevention
hooks/  React-only helpers
di/  client-first DI integration
api/  API clients
infrastructure/  technical concerns
services/  UI orchestration (read-only and write orchestration)
page-queries/  server composition
types/  API transport DTOs
builders/  data transformation (DTO → ViewModel → ViewData)
view-models/
view-data/
view-models/  client-only classes
display-objects/  deterministic formatting helpers
command-models/  transient form models
blockers/  UX-only prevention
hooks/  React-only helpers
di/  client-first DI integration
```

View File

@@ -1,248 +0,0 @@
# Website Guardrails (Mandatory)
This document defines architecture guardrails that must be enforced via tests + ESLint.
Authoritative contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1).
Purpose:
- Encode the architecture as *enforceable* rules.
- Remove ambiguity and prevent drift.
- Make it impossible for `page.tsx` and Templates to accumulate business logic.
## 1) RSC boundary guardrails
Fail CI if any `apps/website/app/**/page.tsx`:
- imports from `apps/website/lib/view-models/*`
- imports from Presenter code (presenters live colocated with ViewModels)
- calls `Intl.*` or `toLocale*`
- performs sorting/filtering (`sort`, `filter`, `reduce`) beyond trivial null checks
Also fail CI if any `apps/website/app/**/page.tsx`:
- imports from `apps/website/lib/display-objects/**`
- imports from `apps/website/lib/services/**` **that are not explicitly server-safe**
- imports from `apps/website/lib/di/**` (server DI ban)
- defines local helper functions other than trivial `assert*`/`invariant*` guards
- contains `new SomeClass()` (object graph construction belongs in PageQueries)
- contains any of these calls (directly or indirectly):
- `ContainerManager.getInstance()`
- `ContainerManager.getContainer()`
Filename rules (route module clarity):
- Only `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, `actions.ts` are allowed under `apps/website/app/**`.
- Fail CI if any file under `apps/website/app/**` matches:
- `*Template.tsx`
- `*ViewModel.ts`
- `*Presenter.ts`
Allowed exception:
- `apps/website/app/<route>/actions.ts` may call services and API clients (server-side), but it must not import ViewModels or Presenters.
## 2) Template purity guardrails
Fail CI if any `apps/website/templates/**`:
- imports from `apps/website/lib/view-models/*`
- imports from presenter code (presenters live colocated with ViewModels)
- imports from `apps/website/lib/display-objects/*`
- calls `Intl.*` or `toLocale*`
Also fail CI if any Template:
- contains `useMemo`, `useEffect`, `useState`, `useReducer` (state belongs in `*PageClient.tsx` and components)
- calls `.filter`, `.sort`, `.reduce` (derived computations must happen before ViewData reaches Templates)
- imports from:
- `apps/website/lib/page-queries/**`
- `apps/website/lib/services/**`
- `apps/website/lib/api/**`
- `apps/website/lib/di/**`
- `apps/website/lib/contracts/**`
Templates accept ViewData only.
Filename + signature rules:
- Template filenames must end with `Template.tsx`.
- The first parameter type of a Template component must be `*ViewData` (or an object containing only `*ViewData` shapes).
- Templates must not export helper functions.
## 3) Display Object guardrails
Fail CI if any `apps/website/lib/display-objects/**`:
- calls `Intl.*` or `toLocale*`
Also fail CI if any Display Object:
- imports from `apps/website/lib/api/**`, `apps/website/lib/services/**`, or `apps/website/lib/page-queries/**` (no IO)
- imports from `apps/website/lib/view-models/**` (direction must be Presenter/ViewModel -> DisplayObject, not vice versa)
- exports non-class members (Display Objects must be class-based)
Display Objects must be deterministic.
## 4) Page Query guardrails (server composition only)
Fail CI if any `apps/website/lib/page-queries/**`:
- imports from `apps/website/lib/view-models/**`
- imports from `apps/website/lib/display-objects/**`
- imports from `apps/website/lib/di/**` or references `ContainerManager`
- calls `Intl.*` or `toLocale*`
- calls `.sort`, `.filter`, `.reduce` (sorting/filtering belongs in API if canonical; otherwise client ViewModel)
- returns `null` (must return `PageQueryResult` union)
Filename rules:
- PageQueries must be named `*PageQuery.ts`.
- Page DTO types must be named `*PageDto` and live next to their PageQuery.
## 5) Services guardrails (DTO-only, server-safe)
Fail CI if any `apps/website/lib/services/**`:
- imports from `apps/website/lib/view-models/**` or `apps/website/templates/**`
- imports from `apps/website/lib/display-objects/**`
- stores state on `this` other than injected dependencies (services must be stateless)
- uses blockers (blockers are client-only UX helpers)
Naming rules:
- Service methods returning API responses should use variable name `apiDto`.
- Service methods returning Page DTO should use variable name `pageDto`.
## 6) Client-only guardrails (ViewModels, Presenters)
Fail CI if any file under `apps/website/lib/view-models/**`:
- lacks `'use client'` at top-level when it exports a ViewModel class intended for instantiation
- imports from `apps/website/lib/page-queries/**` or `apps/website/app/**` (dependency direction violation)
Fail CI if any Presenter/ViewModel uses:
- HTTP calls (`fetch`, axios, API clients)
## 7) Write boundary guardrails (Server Actions only)
Fail CI if any client module (`'use client'` file or `apps/website/components/**`) performs HTTP writes:
- `fetch` with method `POST|PUT|PATCH|DELETE`
Fail CI if any server action (`apps/website/app/**/actions.ts`):
- imports from `apps/website/lib/view-models/**` or `apps/website/templates/**`
- returns ViewModels (must return primitives / redirect / revalidate)
## 8) Model taxonomy guardrails (naming + type suffixes)
Problem being prevented:
- Calling everything “dto” collapses API Transport DTO, Page DTO, and ViewData.
- This causes wrong-layer dependencies and makes reviews error-prone.
Fail CI if any file under `apps/website/**` contains a variable named exactly:
- `dto`
Allowed variable names (pick the right one):
- `apiDto` (API Transport DTO from OpenAPI / backend HTTP)
- `pageDto` (Page DTO assembled by PageQueries)
- `viewData` (Template input)
- `commandDto` (write intent)
Type naming rules (CI should fail if violated):
1. Any PageQuery output type MUST end with `PageDto`.
- Applies to types defined in `apps/website/lib/page-queries/**`.
2. Any Template prop type MUST end with `ViewData`.
- Applies to types used by `apps/website/templates/**`.
3. API Transport DTO types may end with `DTO` (existing generated convention) or `ApiDto` (preferred for hand-written).
Module boundary reinforcement:
- `apps/website/templates/**` MUST NOT import API Transport DTO types directly.
- Prefer: PageQuery emits `pageDto` → Presenter emits `viewData`.
## 9) Contracts enforcement (mandatory interfaces)
Purpose:
- Guardrails that rely on regex alone will always have loopholes.
- Contracts make the compiler enforce architecture: code must implement the right shapes.
These contracts live under:
- `apps/website/lib/contracts/**`
### 9.1 Required contracts
Fail CI if any of these are missing:
1. PageQuery contract: `apps/website/lib/contracts/page-queries/PageQuery.ts`
- Requires `execute(...) -> PageQueryResult<PageDto>`.
2. Service contract(s): `apps/website/lib/contracts/services/*`
- Services return `ApiDto`/`PageDto` only.
- No ViewModels.
3. Presenter contract: `apps/website/lib/contracts/presenters/Presenter.ts`
- `present(input) -> output` (pure, deterministic).
4. ViewModel base: `apps/website/lib/contracts/view-models/ViewModel.ts`
- ViewModels are client-only.
- Must not expose a method that returns Page DTO or API DTO.
### 9.2 Enforcement rules
Fail CI if:
- Any file under `apps/website/lib/page-queries/**` defines a `class *PageQuery` that does NOT implement `PageQuery`.
- Any file under `apps/website/lib/services/**` defines a `class *Service` that does NOT implement a Service contract.
- Any file under `apps/website/lib/view-models/**` defines a `*Presenter` that does NOT implement `Presenter`.
Additionally:
- Fail if a PageQuery returns a shape that is not `PageQueryResult`.
- Fail if a service method returns a `*ViewModel` type.
Note:
- Enforcement can be implemented as a boundary test that parses TypeScript files (or a regex-based approximation as a first step), but the source of truth is: contracts must exist and be implemented.
## 10) Generated DTO isolation (OpenAPI transport types do not reach UI)
Purpose:
- Generated OpenAPI DTOs are transport contracts.
- UI must not depend on transport contracts directly.
- Prevents “DTO soup” and forces the PageDto/ViewData boundary.
Fail CI if any of these import from `apps/website/lib/types/generated/**`:
- `apps/website/templates/**`
- `apps/website/components/**`
- `apps/website/hooks/**` and `apps/website/lib/hooks/**`
Fail CI if any Template imports from `apps/website/lib/types/**`.
Allowed locations for generated DTO imports:
- `apps/website/lib/api/**` (API clients)
- `apps/website/lib/services/**` (transport orchestration)
- `apps/website/lib/page-queries/**` (Page DTO assembly)
Enforced flow:
- Generated `*DTO` -> `apiDto` (API client/service)
- `apiDto` -> `pageDto` (PageQuery)
- `pageDto` -> `viewData` (Presenter)
Rationale:
- If the API contract changes, the blast radius stays in infrastructure + server composition, not in Templates.

View File

@@ -42,18 +42,52 @@ Authoritative contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSIT
- JSON-serializable only.
- Contains only values ready to render (mostly strings/numbers).
- Built from Page DTO (initial render) and from ViewModel (post-hydration).
- Built from API DTO directly in RSC.
The mapping between Page DTO, ViewModel, and ViewData is performed by Presenters.
See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1).
The mapping between API DTO and ViewData is performed by ViewData Builders.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
## 3) Required per-route structure
Every route MUST follow:
### Server Components (RSC)
Every RSC route MUST follow:
1) `page.tsx` (server): calls a PageQuery and passes Page DTO
2) `*PageClient.tsx` (client): builds ViewData and renders Template
3) `*Template.tsx` (pure UI): renders ViewData only
1) `page.tsx`: calls a PageQuery
2) `page.tsx`: builds ViewData using ViewDataBuilder
3) `page.tsx`: renders Template with ViewData
Example:
```typescript
export default async function AdminDashboardPage() {
const apiDto = await AdminDashboardPageQuery.execute();
const viewData = AdminDashboardViewDataBuilder.build(apiDto);
return <AdminDashboardTemplate viewData={viewData} />;
}
```
### Client Components
Client components that need API data MUST follow:
1) `*Client.tsx`: fetches API DTO
2) `*Client.tsx`: builds ViewModel using ViewModelBuilder
3) `*Client.tsx`: renders Template with ViewModel
Example:
```typescript
'use client';
export function AdminDashboardClient() {
const [viewModel, setViewModel] = useState<AdminDashboardViewModel | null>(null);
useEffect(() => {
const apiDto = await adminApiClient.getDashboard();
const vm = AdminDashboardViewModelBuilder.build(apiDto);
setViewModel(vm);
}, []);
return viewModel ? <AdminDashboardTemplate viewModel={viewModel} /> : null;
}
```
All writes enter through Server Actions.
See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1).