website refactor
This commit is contained in:
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
58
apps/website/app/admin/AdminDashboardClient.tsx
Normal file
58
apps/website/app/admin/AdminDashboardClient.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
115
apps/website/app/admin/AdminUsersClient.tsx
Normal file
115
apps/website/app/admin/AdminUsersClient.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
apps/website/app/admin/actions.ts
Normal file
45
apps/website/app/admin/actions.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
132
apps/website/eslint-rules/mutation-contract.js
Normal file
132
apps/website/eslint-rules/mutation-contract.js
Normal 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
89
apps/website/eslint-rules/page-query-use-builder.js
Normal file
89
apps/website/eslint-rules/page-query-use-builder.js
Normal 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
130
apps/website/eslint-rules/server-actions-must-use-mutations.js
Normal file
130
apps/website/eslint-rules/server-actions-must-use-mutations.js
Normal 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
50
apps/website/eslint-rules/view-data-location.js
Normal file
50
apps/website/eslint-rules/view-data-location.js
Normal 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
25
apps/website/lib/contracts/builders/ViewDataBuilder.ts
Normal file
25
apps/website/lib/contracts/builders/ViewDataBuilder.ts
Normal 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;
|
||||
}
|
||||
25
apps/website/lib/contracts/builders/ViewModelBuilder.ts
Normal file
25
apps/website/lib/contracts/builders/ViewModelBuilder.ts
Normal 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;
|
||||
}
|
||||
33
apps/website/lib/contracts/mutations/Mutation.ts
Normal file
33
apps/website/lib/contracts/mutations/Mutation.ts
Normal 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>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageQueryResult } from "@/lib/page-queries/page-query-result/PageQueryResult";
|
||||
import { PageQueryResult } from "./PageQueryResult";
|
||||
|
||||
|
||||
/**
|
||||
|
||||
37
apps/website/lib/mutations/admin/DeleteUserMutation.ts
Normal file
37
apps/website/lib/mutations/admin/DeleteUserMutation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
apps/website/lib/mutations/admin/UpdateUserStatusMutation.ts
Normal file
37
apps/website/lib/mutations/admin/UpdateUserStatusMutation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
50
apps/website/lib/page-queries/AdminDashboardPageQuery.ts
Normal file
50
apps/website/lib/page-queries/AdminDashboardPageQuery.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
93
apps/website/lib/page-queries/AdminUsersPageQuery.ts
Normal file
93
apps/website/lib/page-queries/AdminUsersPageQuery.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/website/lib/view-data/AdminDashboardViewData.ts
Normal file
17
apps/website/lib/view-data/AdminDashboardViewData.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
136
apps/website/templates/AdminDashboardTemplate.tsx
Normal file
136
apps/website/templates/AdminDashboardTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
apps/website/templates/AdminUsersTemplate.tsx
Normal file
335
apps/website/templates/AdminUsersTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/website/templates/AdminUsersViewData.ts
Normal file
24
apps/website/templates/AdminUsersViewData.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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': [],
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
207
docs/architecture/website/BUILDERS.md
Normal file
207
docs/architecture/website/BUILDERS.md
Normal 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.
|
||||
151
docs/architecture/website/MUTATIONS.md
Normal file
151
docs/architecture/website/MUTATIONS.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
231
docs/architecture/website/SERVICES.md
Normal file
231
docs/architecture/website/SERVICES.md
Normal 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**
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
109
docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md
Normal file
109
docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user