website refactor
This commit is contained in:
@@ -48,7 +48,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"display-no-domain-models": "error",
|
"display-no-domain-models": "error",
|
||||||
"display-no-business-logic": "error",
|
"display-no-business-logic": "error",
|
||||||
"model-no-domain-in-display": "error"
|
"model-no-domain-in-display": "error",
|
||||||
|
"filename-display-match": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,17 @@
|
|||||||
"no-restricted-syntax": "off"
|
"no-restricted-syntax": "off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"lib/presenters/**/*.ts",
|
||||||
|
"lib/presenters/**/*.tsx",
|
||||||
|
"lib/view-models/**/*.ts",
|
||||||
|
"lib/view-models/**/*.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"gridpilot-rules/presenter-contract": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"lib/builders/view-models/*.ts",
|
"lib/builders/view-models/*.ts",
|
||||||
@@ -42,7 +53,18 @@
|
|||||||
"lib/builders/view-data/*.tsx"
|
"lib/builders/view-data/*.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/view-data-builder-contract": "error"
|
"gridpilot-rules/view-data-builder-contract": "error",
|
||||||
|
"gridpilot-rules/single-export-per-file": "error",
|
||||||
|
"gridpilot-rules/filename-matches-export": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"lib/builders/**/*.ts",
|
||||||
|
"lib/builders/**/*.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"gridpilot-rules/no-page-dtos-directory": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,7 +127,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/display-no-domain-models": "error",
|
"gridpilot-rules/display-no-domain-models": "error",
|
||||||
"gridpilot-rules/display-no-business-logic": "error",
|
"gridpilot-rules/display-no-business-logic": "error",
|
||||||
"gridpilot-rules/model-no-domain-in-display": "error"
|
"gridpilot-rules/model-no-domain-in-display": "error",
|
||||||
|
"gridpilot-rules/filename-display-match": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,7 +140,22 @@
|
|||||||
"gridpilot-rules/page-query-filename": "error",
|
"gridpilot-rules/page-query-filename": "error",
|
||||||
"gridpilot-rules/page-query-contract": "error",
|
"gridpilot-rules/page-query-contract": "error",
|
||||||
"gridpilot-rules/page-query-execute": "error",
|
"gridpilot-rules/page-query-execute": "error",
|
||||||
"gridpilot-rules/page-query-return-type": "error"
|
"gridpilot-rules/page-query-return-type": "error",
|
||||||
|
"gridpilot-rules/page-query-must-use-builders": "error",
|
||||||
|
"gridpilot-rules/single-export-per-file": "error",
|
||||||
|
"gridpilot-rules/filename-matches-export": "error",
|
||||||
|
"gridpilot-rules/clean-error-handling": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"lib/mutations/**/*.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"gridpilot-rules/mutation-contract": "error",
|
||||||
|
"gridpilot-rules/clean-error-handling": "error",
|
||||||
|
"gridpilot-rules/single-export-per-file": "error",
|
||||||
|
"gridpilot-rules/filename-matches-export": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,7 +174,9 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/services-no-external-api": "error",
|
"gridpilot-rules/services-no-external-api": "error",
|
||||||
"gridpilot-rules/services-must-be-pure": "error",
|
"gridpilot-rules/services-must-be-pure": "error",
|
||||||
"gridpilot-rules/filename-service-match": "error"
|
"gridpilot-rules/filename-service-match": "error",
|
||||||
|
"gridpilot-rules/services-must-return-result": "error",
|
||||||
|
"gridpilot-rules/services-implement-contract": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,6 +207,27 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/model-no-display-in-domain": "error"
|
"gridpilot-rules/model-no-display-in-domain": "error"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"lib/**/*.ts",
|
||||||
|
"lib/**/*.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"gridpilot-rules/lib-no-next-imports": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"lib/services/**/*.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"gridpilot-rules/service-function-format": "error",
|
||||||
|
"gridpilot-rules/services-must-be-marked": "error",
|
||||||
|
"gridpilot-rules/services-must-be-pure": "error",
|
||||||
|
"gridpilot-rules/services-no-external-api": "error",
|
||||||
|
"gridpilot-rules/services-no-instantiation": "error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|||||||
118
apps/website/eslint-rules/clean-error-handling.js
Normal file
118
apps/website/eslint-rules/clean-error-handling.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to enforce clean error handling architecture
|
||||||
|
*
|
||||||
|
* PageQueries and Mutations must:
|
||||||
|
* 1. Use Services for data access
|
||||||
|
* 2. Services must return Result types
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce clean error handling architecture in PageQueries and Mutations',
|
||||||
|
category: 'Architecture',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
mustUseServices: 'PageQueries and Mutations must use Services for data access, not API Clients directly.',
|
||||||
|
servicesMustReturnResult: 'Services must return Result<T, DomainError> for type-safe error handling.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isPageQuery = filename.includes('/lib/page-queries/');
|
||||||
|
const isMutation = filename.includes('/lib/mutations/');
|
||||||
|
const isService = filename.includes('/lib/services/');
|
||||||
|
const isRelevant = isPageQuery || isMutation || isService;
|
||||||
|
|
||||||
|
if (!isRelevant) return {};
|
||||||
|
|
||||||
|
// Track imports
|
||||||
|
const apiClientImports = new Set();
|
||||||
|
const serviceImports = new Set();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Track imports
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
node.specifiers.forEach(spec => {
|
||||||
|
const importPath = node.source.value;
|
||||||
|
if (importPath.includes('/lib/api/')) {
|
||||||
|
apiClientImports.add(spec.local.name);
|
||||||
|
}
|
||||||
|
if (importPath.includes('/lib/services/')) {
|
||||||
|
serviceImports.add(spec.local.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check PageQueries/Mutations for direct API Client usage
|
||||||
|
NewExpression(node) {
|
||||||
|
if (node.callee.type === 'Identifier') {
|
||||||
|
const className = node.callee.name;
|
||||||
|
|
||||||
|
// Only check in PageQueries and Mutations
|
||||||
|
if ((isPageQuery || isMutation) &&
|
||||||
|
(className.endsWith('ApiClient') || className.endsWith('Api'))) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'mustUseServices',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check Services for Result return type
|
||||||
|
MethodDefinition(node) {
|
||||||
|
if (isService && node.key.type === 'Identifier' && node.key.name === 'execute') {
|
||||||
|
const returnType = node.value.returnType;
|
||||||
|
|
||||||
|
if (!returnType ||
|
||||||
|
!returnType.typeAnnotation ||
|
||||||
|
!returnType.typeAnnotation.typeName ||
|
||||||
|
returnType.typeAnnotation.typeName.name !== 'Promise') {
|
||||||
|
// Missing Promise return type
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'servicesMustReturnResult',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Result type
|
||||||
|
const typeArgs = returnType.typeAnnotation.typeParameters;
|
||||||
|
if (!typeArgs || !typeArgs.params || typeArgs.params.length === 0) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'servicesMustReturnResult',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultType = typeArgs.params[0];
|
||||||
|
if (resultType.type !== 'TSTypeReference' ||
|
||||||
|
!resultType.typeName ||
|
||||||
|
(resultType.typeName.type === 'Identifier' && resultType.typeName.name !== 'Result')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'servicesMustReturnResult',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check that PageQueries/Mutations have Service imports
|
||||||
|
'Program:exit'() {
|
||||||
|
if ((isPageQuery || isMutation) && serviceImports.size === 0) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'mustUseServices',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -37,6 +37,9 @@ const pageQueryMustUseBuilders = require('./page-query-must-use-builders');
|
|||||||
const serviceFunctionFormat = require('./service-function-format');
|
const serviceFunctionFormat = require('./service-function-format');
|
||||||
const libNoNextImports = require('./lib-no-next-imports');
|
const libNoNextImports = require('./lib-no-next-imports');
|
||||||
const servicesNoInstantiation = require('./services-no-instantiation');
|
const servicesNoInstantiation = require('./services-no-instantiation');
|
||||||
|
const noPageDtosDirectory = require('./no-page-dtos-directory');
|
||||||
|
const cleanErrorHandling = require('./clean-error-handling');
|
||||||
|
const servicesImplementContract = require('./services-implement-contract');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rules: {
|
rules: {
|
||||||
@@ -82,6 +85,7 @@ module.exports = {
|
|||||||
'services-must-be-marked': servicesRules['services-must-be-marked'],
|
'services-must-be-marked': servicesRules['services-must-be-marked'],
|
||||||
'services-no-external-api': servicesRules['no-external-api-in-services'],
|
'services-no-external-api': servicesRules['no-external-api-in-services'],
|
||||||
'services-must-be-pure': servicesRules['services-must-be-pure'],
|
'services-must-be-pure': servicesRules['services-must-be-pure'],
|
||||||
|
'services-must-return-result': cleanErrorHandling,
|
||||||
|
|
||||||
// Client-Only Rules
|
// Client-Only Rules
|
||||||
'client-only-no-server-code': clientOnlyRules['no-server-code-in-client-only'],
|
'client-only-no-server-code': clientOnlyRules['no-server-code-in-client-only'],
|
||||||
@@ -127,6 +131,13 @@ module.exports = {
|
|||||||
'service-function-format': serviceFunctionFormat,
|
'service-function-format': serviceFunctionFormat,
|
||||||
'lib-no-next-imports': libNoNextImports,
|
'lib-no-next-imports': libNoNextImports,
|
||||||
'services-no-instantiation': servicesNoInstantiation,
|
'services-no-instantiation': servicesNoInstantiation,
|
||||||
|
|
||||||
|
// Page DTO Rules
|
||||||
|
'no-page-dtos-directory': noPageDtosDirectory,
|
||||||
|
|
||||||
|
// Clean Error Handling Rules
|
||||||
|
'clean-error-handling': cleanErrorHandling,
|
||||||
|
'services-implement-contract': servicesImplementContract,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Configurations for different use cases
|
// Configurations for different use cases
|
||||||
|
|||||||
37
apps/website/eslint-rules/no-page-dtos-directory.js
Normal file
37
apps/website/eslint-rules/no-page-dtos-directory.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to forbid lib/builders/page-dtos directory
|
||||||
|
*
|
||||||
|
* This directory is completely forbidden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Forbid lib/builders/page-dtos directory',
|
||||||
|
category: 'Best Practices',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
forbiddenDirectory: 'The lib/builders/page-dtos directory is forbidden. Use lib/builders/view-data/ or lib/builders/view-models/ instead.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
// Check if file is in the forbidden directory
|
||||||
|
if (filename.includes('lib/builders/page-dtos')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'forbiddenDirectory',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ESLint rule to enforce PageQueries use Builders
|
* ESLint rule to enforce PageQueries use Builders
|
||||||
*
|
*
|
||||||
* PageQueries should not manually transform API DTOs.
|
* PageQueries should not manually transform API DTOs or return them directly.
|
||||||
* They must use Builder classes to transform API DTOs to View Data.
|
* They must use Builder classes to transform API DTOs to View Data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ module.exports = {
|
|||||||
fixable: null,
|
fixable: null,
|
||||||
schema: [],
|
schema: [],
|
||||||
messages: {
|
messages: {
|
||||||
mustUseBuilder: 'PageQueries must use Builder classes to transform API DTOs. Found manual object literal transformation in execute() method.',
|
mustUseBuilder: 'PageQueries must use Builder classes to transform API DTOs to View Data. Found manual transformation or direct API DTO return.',
|
||||||
multipleExports: 'PageQuery files should only export the PageQuery class, not DTOs.',
|
multipleExports: 'PageQuery files should only export the PageQuery class, not DTOs.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -25,8 +25,17 @@ module.exports = {
|
|||||||
let inPageQueryExecute = false;
|
let inPageQueryExecute = false;
|
||||||
let hasManualTransformation = false;
|
let hasManualTransformation = false;
|
||||||
let hasMultipleExports = false;
|
let hasMultipleExports = false;
|
||||||
|
let hasBuilderCall = false;
|
||||||
|
let pageQueryClassName = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Track PageQuery class name
|
||||||
|
ClassDeclaration(node) {
|
||||||
|
if (node.id && node.id.name && node.id.name.endsWith('PageQuery')) {
|
||||||
|
pageQueryClassName = node.id.name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Track PageQuery class execute method
|
// Track PageQuery class execute method
|
||||||
MethodDefinition(node) {
|
MethodDefinition(node) {
|
||||||
if (node.key.type === 'Identifier' &&
|
if (node.key.type === 'Identifier' &&
|
||||||
@@ -41,19 +50,75 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Detect Builder calls
|
||||||
|
CallExpression(node) {
|
||||||
|
if (inPageQueryExecute) {
|
||||||
|
// Check for Builder.build() or Builder.createViewData()
|
||||||
|
if (node.callee.type === 'MemberExpression' &&
|
||||||
|
node.callee.property.type === 'Identifier' &&
|
||||||
|
(node.callee.property.name === 'build' || node.callee.property.name === 'createViewData')) {
|
||||||
|
|
||||||
|
// Check if the object is a Builder
|
||||||
|
if (node.callee.object.type === 'Identifier' &&
|
||||||
|
(node.callee.object.name.includes('Builder') ||
|
||||||
|
node.callee.object.name.includes('builder'))) {
|
||||||
|
hasBuilderCall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Detect object literal assignments (manual transformation)
|
// Detect object literal assignments (manual transformation)
|
||||||
VariableDeclarator(node) {
|
VariableDeclarator(node) {
|
||||||
if (inPageQueryExecute && node.init && node.init.type === 'ObjectExpression') {
|
if (inPageQueryExecute && node.init && node.init.type === 'ObjectExpression') {
|
||||||
// This is: const dto = { ... }
|
|
||||||
hasManualTransformation = true;
|
hasManualTransformation = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Detect object literal in return statements
|
// Detect object literal in return statements
|
||||||
ReturnStatement(node) {
|
ReturnStatement(node) {
|
||||||
if (inPageQueryExecute && node.argument && node.argument.type === 'ObjectExpression') {
|
if (inPageQueryExecute && node.argument) {
|
||||||
// This is: return { ... }
|
// Direct object literal return
|
||||||
hasManualTransformation = true;
|
if (node.argument.type === 'ObjectExpression') {
|
||||||
|
hasManualTransformation = true;
|
||||||
|
}
|
||||||
|
// Direct identifier return (likely API DTO)
|
||||||
|
else if (node.argument.type === 'Identifier' &&
|
||||||
|
!node.argument.name.includes('ViewData') &&
|
||||||
|
!node.argument.name.includes('viewData')) {
|
||||||
|
// This might be returning an API DTO directly
|
||||||
|
// We'll flag it as manual transformation since no builder was used
|
||||||
|
if (!hasBuilderCall) {
|
||||||
|
hasManualTransformation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CallExpression like Result.ok(apiDto) or Result.err()
|
||||||
|
else if (node.argument.type === 'CallExpression') {
|
||||||
|
// Check if it's a Result call with an identifier argument
|
||||||
|
const callExpr = node.argument;
|
||||||
|
if (callExpr.callee.type === 'MemberExpression' &&
|
||||||
|
callExpr.callee.object.type === 'Identifier' &&
|
||||||
|
callExpr.callee.object.name === 'Result' &&
|
||||||
|
callExpr.callee.property.type === 'Identifier' &&
|
||||||
|
(callExpr.callee.property.name === 'ok' || callExpr.callee.property.name === 'err')) {
|
||||||
|
|
||||||
|
// If it's Result.ok(someIdentifier), check if the identifier is likely an API DTO
|
||||||
|
if (callExpr.callee.property.name === 'ok' &&
|
||||||
|
callExpr.arguments.length > 0 &&
|
||||||
|
callExpr.arguments[0].type === 'Identifier') {
|
||||||
|
const argName = callExpr.arguments[0].name;
|
||||||
|
// Common API DTO naming patterns
|
||||||
|
const isApiDto = argName.includes('Dto') ||
|
||||||
|
argName.includes('api') ||
|
||||||
|
argName === 'result' ||
|
||||||
|
argName === 'data';
|
||||||
|
|
||||||
|
if (isApiDto && !hasBuilderCall) {
|
||||||
|
hasManualTransformation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -81,7 +146,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'Program:exit'() {
|
'Program:exit'() {
|
||||||
if (hasManualTransformation) {
|
// Only report if no builder was used
|
||||||
|
if (hasManualTransformation && !hasBuilderCall) {
|
||||||
context.report({
|
context.report({
|
||||||
node: context.getSourceCode().ast,
|
node: context.getSourceCode().ast,
|
||||||
messageId: 'mustUseBuilder',
|
messageId: 'mustUseBuilder',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* ESLint rule to enforce Service function format
|
* ESLint rule to enforce Service function format
|
||||||
*
|
*
|
||||||
* Services in lib/services/ must:
|
* Services in lib/services/ must:
|
||||||
* 1. Be classes named *Service (not functions)
|
* 1. Be classes named *Service (not functions)
|
||||||
* 2. Not have side effects (no redirect, console.log, etc.)
|
* 2. Create their own dependencies (API Client, Logger, ErrorReporter)
|
||||||
* 3. Use builders for data transformation
|
* 3. Return Result types
|
||||||
|
* 4. NOT use redirect() or process.exit()
|
||||||
|
* 5. CAN use console.error() for logging (allowed)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -19,20 +21,22 @@ module.exports = {
|
|||||||
schema: [],
|
schema: [],
|
||||||
messages: {
|
messages: {
|
||||||
notAClass: 'Services must be classes named *Service, not functions. Found function "{{name}}" in lib/services/',
|
notAClass: 'Services must be classes named *Service, not functions. Found function "{{name}}" in lib/services/',
|
||||||
hasSideEffects: 'Services must be pure. Found side effect: {{effect}}',
|
|
||||||
noRedirect: 'Services cannot use redirect(). Use PageQueries or Client Components for navigation.',
|
noRedirect: 'Services cannot use redirect(). Use PageQueries or Client Components for navigation.',
|
||||||
|
noProcessExit: 'Services cannot use process.exit().',
|
||||||
multipleExports: 'Service files should only export the Service class.',
|
multipleExports: 'Service files should only export the Service class.',
|
||||||
|
mustReturnResult: 'Service methods must return Result<T, DomainError>.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
create(context) {
|
create(context) {
|
||||||
const filename = context.getFilename();
|
const filename = context.getFilename();
|
||||||
const isInServices = filename.includes('/lib/services/');
|
const isInServices = filename.includes('/lib/services/');
|
||||||
let hasSideEffect = false;
|
let hasRedirect = false;
|
||||||
let sideEffectType = '';
|
let hasProcessExit = false;
|
||||||
let hasMultipleExports = false;
|
let hasMultipleExports = false;
|
||||||
let hasFunctionExport = false;
|
let hasFunctionExport = false;
|
||||||
let functionName = '';
|
let functionName = '';
|
||||||
|
let hasResultReturningMethod = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Track function declarations
|
// Track function declarations
|
||||||
@@ -59,30 +63,20 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Track redirect calls
|
// Track redirect and process.exit calls
|
||||||
CallExpression(node) {
|
CallExpression(node) {
|
||||||
if (isInServices) {
|
if (isInServices) {
|
||||||
// Check for redirect()
|
// Check for redirect()
|
||||||
if (node.callee.type === 'Identifier' && node.callee.name === 'redirect') {
|
if (node.callee.type === 'Identifier' && node.callee.name === 'redirect') {
|
||||||
hasSideEffect = true;
|
hasRedirect = true;
|
||||||
sideEffectType = 'redirect()';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for console.log, console.error, etc.
|
|
||||||
if (node.callee.type === 'MemberExpression' &&
|
|
||||||
node.callee.object.type === 'Identifier' &&
|
|
||||||
node.callee.object.name === 'console') {
|
|
||||||
hasSideEffect = true;
|
|
||||||
sideEffectType = 'console.' + (node.callee.property.name || 'call');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for process.exit()
|
// Check for process.exit()
|
||||||
if (node.callee.type === 'MemberExpression' &&
|
if (node.callee.type === 'MemberExpression' &&
|
||||||
node.callee.object.type === 'Identifier' &&
|
node.callee.object.type === 'Identifier' &&
|
||||||
node.callee.object.name === 'process' &&
|
node.callee.object.name === 'process' &&
|
||||||
node.callee.property.name === 'exit') {
|
node.callee.property.name === 'exit') {
|
||||||
hasSideEffect = true;
|
hasProcessExit = true;
|
||||||
sideEffectType = 'process.exit()';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -109,6 +103,30 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Check for Result-returning methods
|
||||||
|
MethodDefinition(node) {
|
||||||
|
if (isInServices && node.key.type === 'Identifier') {
|
||||||
|
const returnType = node.value?.returnType;
|
||||||
|
|
||||||
|
if (returnType &&
|
||||||
|
returnType.typeAnnotation &&
|
||||||
|
returnType.typeAnnotation.typeName &&
|
||||||
|
returnType.typeAnnotation.typeName.name === 'Promise' &&
|
||||||
|
returnType.typeAnnotation.typeParameters &&
|
||||||
|
returnType.typeAnnotation.typeParameters.params.length > 0) {
|
||||||
|
|
||||||
|
const resultType = returnType.typeAnnotation.typeParameters.params[0];
|
||||||
|
|
||||||
|
if (resultType.type === 'TSTypeReference' &&
|
||||||
|
resultType.typeName &&
|
||||||
|
resultType.typeName.type === 'Identifier' &&
|
||||||
|
resultType.typeName.name === 'Result') {
|
||||||
|
hasResultReturningMethod = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
'Program:exit'() {
|
'Program:exit'() {
|
||||||
if (!isInServices) return;
|
if (!isInServices) return;
|
||||||
|
|
||||||
@@ -121,12 +139,19 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for side effects
|
// Check for redirect
|
||||||
if (hasSideEffect) {
|
if (hasRedirect) {
|
||||||
context.report({
|
context.report({
|
||||||
node: context.getSourceCode().ast,
|
node: context.getSourceCode().ast,
|
||||||
messageId: sideEffectType === 'redirect()' ? 'noRedirect' : 'hasSideEffects',
|
messageId: 'noRedirect',
|
||||||
data: { effect: sideEffectType },
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for process.exit
|
||||||
|
if (hasProcessExit) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'noProcessExit',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +162,10 @@ module.exports = {
|
|||||||
messageId: 'multipleExports',
|
messageId: 'multipleExports',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for Result-returning methods (warn if none found)
|
||||||
|
// Note: This is a soft check - services might have private methods that don't return Result
|
||||||
|
// The important thing is that the public API methods do
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
133
apps/website/eslint-rules/services-implement-contract.js
Normal file
133
apps/website/eslint-rules/services-implement-contract.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to enforce Services follow clean architecture patterns
|
||||||
|
*
|
||||||
|
* Services must:
|
||||||
|
* 1. Return Result types for type-safe error handling
|
||||||
|
* 2. Use DomainError types (not strings)
|
||||||
|
* 3. Be classes named *Service
|
||||||
|
* 4. Create their own dependencies (API Client, Logger, ErrorReporter)
|
||||||
|
* 5. Have NO constructor parameters (self-contained)
|
||||||
|
*
|
||||||
|
* Note: Method names can vary (execute(), getSomething(), etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce Services follow clean architecture patterns',
|
||||||
|
category: 'Services',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
mustReturnResult: 'Service methods must return Promise<Result<T, DomainError>>',
|
||||||
|
mustUseDomainError: 'Error types must be DomainError objects, not strings',
|
||||||
|
noConstructorParams: 'Services must be self-contained. Constructor cannot have parameters. Dependencies should be created inside the constructor.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isInServices = filename.includes('/lib/services/');
|
||||||
|
|
||||||
|
if (!isInServices) return {};
|
||||||
|
|
||||||
|
let hasResultReturningMethod = false;
|
||||||
|
let usesStringErrors = false;
|
||||||
|
let hasConstructorParams = false;
|
||||||
|
let classNode = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ClassDeclaration(node) {
|
||||||
|
classNode = node;
|
||||||
|
const className = node.id?.name;
|
||||||
|
|
||||||
|
if (!className || !className.endsWith('Service')) {
|
||||||
|
return; // Not a service class
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all methods for Result return types
|
||||||
|
node.body.body.forEach(member => {
|
||||||
|
if (member.type === 'MethodDefinition' &&
|
||||||
|
member.key.type === 'Identifier') {
|
||||||
|
|
||||||
|
// Check constructor parameters
|
||||||
|
if (member.kind === 'constructor') {
|
||||||
|
const params = member.value.params;
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
hasConstructorParams = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check return types
|
||||||
|
const returnType = member.value?.returnType;
|
||||||
|
|
||||||
|
if (returnType &&
|
||||||
|
returnType.typeAnnotation &&
|
||||||
|
returnType.typeAnnotation.typeName &&
|
||||||
|
returnType.typeAnnotation.typeName.name === 'Promise' &&
|
||||||
|
returnType.typeAnnotation.typeParameters &&
|
||||||
|
returnType.typeAnnotation.typeParameters.params.length > 0) {
|
||||||
|
|
||||||
|
const resultType = returnType.typeAnnotation.typeParameters.params[0];
|
||||||
|
|
||||||
|
// Check for Result<...>
|
||||||
|
if (resultType.type === 'TSTypeReference' &&
|
||||||
|
resultType.typeName &&
|
||||||
|
resultType.typeName.type === 'Identifier' &&
|
||||||
|
resultType.typeName.name === 'Result') {
|
||||||
|
hasResultReturningMethod = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for string error type
|
||||||
|
if (resultType.type === 'TSTypeReference' &&
|
||||||
|
resultType.typeParameters &&
|
||||||
|
resultType.typeParameters.params.length > 1) {
|
||||||
|
|
||||||
|
const errorType = resultType.typeParameters.params[1];
|
||||||
|
// Check if error is string literal or string type
|
||||||
|
if (errorType.type === 'TSStringKeyword' ||
|
||||||
|
(errorType.type === 'TSLiteralType' && errorType.literal.type === 'StringLiteral')) {
|
||||||
|
usesStringErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'Program:exit'() {
|
||||||
|
if (!isInServices || !classNode) return;
|
||||||
|
|
||||||
|
const className = classNode.id?.name;
|
||||||
|
if (!className || !className.endsWith('Service')) return;
|
||||||
|
|
||||||
|
// Error if constructor has parameters
|
||||||
|
if (hasConstructorParams) {
|
||||||
|
context.report({
|
||||||
|
node: classNode,
|
||||||
|
messageId: 'noConstructorParams',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error if no methods return Result
|
||||||
|
if (!hasResultReturningMethod) {
|
||||||
|
context.report({
|
||||||
|
node: classNode,
|
||||||
|
messageId: 'mustReturnResult',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error if using string errors
|
||||||
|
if (usesStringErrors) {
|
||||||
|
context.report({
|
||||||
|
node: classNode,
|
||||||
|
messageId: 'mustUseDomainError',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
62
apps/website/eslint-rules/test-rule-debug.js
Normal file
62
apps/website/eslint-rules/test-rule-debug.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Debug test for page-query-must-use-builders rule
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rule = require('./page-query-must-use-builders.js');
|
||||||
|
|
||||||
|
// Simulate the AST traversal with logging
|
||||||
|
const mockContext = {
|
||||||
|
getSourceCode: () => ({ ast: {} }),
|
||||||
|
report: (data) => {
|
||||||
|
console.log('✓ REPORT TRIGGERED:', data.messageId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visitor = rule.create(mockContext);
|
||||||
|
|
||||||
|
console.log('=== Starting Debug Test ===\n');
|
||||||
|
|
||||||
|
// 1. ClassDeclaration
|
||||||
|
console.log('1. ClassDeclaration: AdminDashboardPageQuery');
|
||||||
|
visitor.ClassDeclaration({
|
||||||
|
id: { name: 'AdminDashboardPageQuery' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. MethodDefinition (execute)
|
||||||
|
console.log('2. MethodDefinition: execute()');
|
||||||
|
visitor.MethodDefinition({
|
||||||
|
key: { type: 'Identifier', name: 'execute' },
|
||||||
|
parent: { type: 'ClassBody' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. VariableDeclarator
|
||||||
|
console.log('3. VariableDeclarator: const apiDto = ...');
|
||||||
|
visitor.VariableDeclarator({
|
||||||
|
init: { type: 'CallExpression' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. ReturnStatement
|
||||||
|
console.log('4. ReturnStatement: return Result.ok(apiDto)');
|
||||||
|
visitor.ReturnStatement({
|
||||||
|
argument: {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: { type: 'Identifier', name: 'Result' },
|
||||||
|
property: { type: 'Identifier', name: 'ok' }
|
||||||
|
},
|
||||||
|
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. MethodDefinition:exit
|
||||||
|
console.log('5. MethodDefinition:exit');
|
||||||
|
visitor['MethodDefinition:exit']({
|
||||||
|
key: { type: 'Identifier', name: 'execute' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Program:exit
|
||||||
|
console.log('6. Program:exit');
|
||||||
|
visitor['Program:exit']();
|
||||||
|
|
||||||
|
console.log('\n=== Test Complete ===');
|
||||||
58
apps/website/eslint-rules/test-rule-fixed.js
Normal file
58
apps/website/eslint-rules/test-rule-fixed.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Fixed test with proper parent structure
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rule = require('./page-query-must-use-builders.js');
|
||||||
|
|
||||||
|
const mockContext = {
|
||||||
|
getSourceCode: () => ({ ast: {} }),
|
||||||
|
report: (data) => {
|
||||||
|
console.log('✓ REPORT:', data.messageId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visitor = rule.create(mockContext);
|
||||||
|
|
||||||
|
console.log('=== Fixed Test ===\n');
|
||||||
|
|
||||||
|
// 1. ClassDeclaration
|
||||||
|
const classNode = {
|
||||||
|
id: { name: 'AdminDashboardPageQuery' }
|
||||||
|
};
|
||||||
|
visitor.ClassDeclaration(classNode);
|
||||||
|
|
||||||
|
// 2. MethodDefinition (with proper parent structure)
|
||||||
|
const methodNode = {
|
||||||
|
key: { type: 'Identifier', name: 'execute' },
|
||||||
|
parent: {
|
||||||
|
type: 'ClassBody',
|
||||||
|
parent: classNode // This is what was missing!
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visitor.MethodDefinition(methodNode);
|
||||||
|
|
||||||
|
// 3. VariableDeclarator
|
||||||
|
visitor.VariableDeclarator({
|
||||||
|
init: { type: 'CallExpression' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. ReturnStatement
|
||||||
|
visitor.ReturnStatement({
|
||||||
|
argument: {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: { type: 'Identifier', name: 'Result' },
|
||||||
|
property: { type: 'Identifier', name: 'ok' }
|
||||||
|
},
|
||||||
|
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. MethodDefinition:exit
|
||||||
|
visitor['MethodDefinition:exit'](methodNode);
|
||||||
|
|
||||||
|
// 6. Program:exit
|
||||||
|
visitor['Program:exit']();
|
||||||
|
|
||||||
|
console.log('\n=== Done ===');
|
||||||
58
apps/website/eslint-rules/test-rule-simple.js
Normal file
58
apps/website/eslint-rules/test-rule-simple.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Simple test for page-query-must-use-builders rule
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rule = require('./page-query-must-use-builders.js');
|
||||||
|
|
||||||
|
// Simulate the AST traversal
|
||||||
|
const mockContext = {
|
||||||
|
getSourceCode: () => ({ ast: {} }),
|
||||||
|
report: (data) => {
|
||||||
|
console.log('REPORT:', data.messageId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visitor = rule.create(mockContext);
|
||||||
|
|
||||||
|
// Simulate visiting AdminDashboardPageQuery
|
||||||
|
console.log('Starting test...');
|
||||||
|
|
||||||
|
// 1. ClassDeclaration
|
||||||
|
visitor.ClassDeclaration({
|
||||||
|
id: { name: 'AdminDashboardPageQuery' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. MethodDefinition (execute)
|
||||||
|
visitor.MethodDefinition({
|
||||||
|
key: { type: 'Identifier', name: 'execute' },
|
||||||
|
parent: { type: 'ClassBody' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. VariableDeclarator (const apiDto = ...) - NOT an ObjectExpression
|
||||||
|
visitor.VariableDeclarator({
|
||||||
|
init: { type: 'CallExpression' } // apiDto = await adminService.getDashboardStats()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. ReturnStatement (return Result.ok(apiDto))
|
||||||
|
// This is a CallExpression with callee = Result.ok and argument = apiDto
|
||||||
|
visitor.ReturnStatement({
|
||||||
|
argument: {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: { type: 'Identifier', name: 'Result' },
|
||||||
|
property: { type: 'Identifier', name: 'ok' }
|
||||||
|
},
|
||||||
|
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. MethodDefinition:exit
|
||||||
|
visitor['MethodDefinition:exit']({
|
||||||
|
key: { type: 'Identifier', name: 'execute' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Program:exit
|
||||||
|
visitor['Program:exit']();
|
||||||
|
|
||||||
|
console.log('Test complete');
|
||||||
190
apps/website/eslint-rules/test-rule-with-logging.js
Normal file
190
apps/website/eslint-rules/test-rule-with-logging.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Test with logging inside the rule
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Test',
|
||||||
|
category: 'Page Query',
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
mustUseBuilder: 'Must use builders',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
let inPageQueryExecute = false;
|
||||||
|
let hasManualTransformation = false;
|
||||||
|
let hasBuilderCall = false;
|
||||||
|
let pageQueryClassName = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ClassDeclaration(node) {
|
||||||
|
if (node.id && node.id.name && node.id.name.endsWith('PageQuery')) {
|
||||||
|
pageQueryClassName = node.id.name;
|
||||||
|
console.log(' [ClassDeclaration] Found:', pageQueryClassName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
MethodDefinition(node) {
|
||||||
|
if (node.key.type === 'Identifier' &&
|
||||||
|
node.key.name === 'execute' &&
|
||||||
|
node.parent.type === 'ClassBody') {
|
||||||
|
|
||||||
|
const classNode = node.parent.parent;
|
||||||
|
if (classNode && classNode.id && classNode.id.name &&
|
||||||
|
classNode.id.name.endsWith('PageQuery')) {
|
||||||
|
inPageQueryExecute = true;
|
||||||
|
console.log(' [MethodDefinition] execute() in PageQuery');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
CallExpression(node) {
|
||||||
|
if (inPageQueryExecute) {
|
||||||
|
console.log(' [CallExpression] inside execute:', node.callee.type);
|
||||||
|
if (node.callee.type === 'MemberExpression' &&
|
||||||
|
node.callee.property.type === 'Identifier' &&
|
||||||
|
(node.callee.property.name === 'build' || node.callee.property.name === 'createViewData')) {
|
||||||
|
console.log(' [CallExpression] Found Builder call!');
|
||||||
|
hasBuilderCall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
VariableDeclarator(node) {
|
||||||
|
if (inPageQueryExecute && node.init && node.init.type === 'ObjectExpression') {
|
||||||
|
console.log(' [VariableDeclarator] ObjectExpression found');
|
||||||
|
hasManualTransformation = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
ReturnStatement(node) {
|
||||||
|
if (inPageQueryExecute && node.argument) {
|
||||||
|
console.log(' [ReturnStatement] type:', node.argument.type);
|
||||||
|
|
||||||
|
// Direct object literal return
|
||||||
|
if (node.argument.type === 'ObjectExpression') {
|
||||||
|
console.log(' [ReturnStatement] ObjectExpression - setting manual transformation');
|
||||||
|
hasManualTransformation = true;
|
||||||
|
}
|
||||||
|
// Direct identifier return
|
||||||
|
else if (node.argument.type === 'Identifier') {
|
||||||
|
console.log(' [ReturnStatement] Identifier:', node.argument.name);
|
||||||
|
if (!node.argument.name.includes('ViewData') && !node.argument.name.includes('viewData')) {
|
||||||
|
console.log(' [ReturnStatement] Not ViewData, checking builder call...');
|
||||||
|
if (!hasBuilderCall) {
|
||||||
|
console.log(' [ReturnStatement] No builder call - setting manual transformation');
|
||||||
|
hasManualTransformation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CallExpression
|
||||||
|
else if (node.argument.type === 'CallExpression') {
|
||||||
|
console.log(' [ReturnStatement] CallExpression');
|
||||||
|
const callExpr = node.argument;
|
||||||
|
if (callExpr.callee.type === 'MemberExpression' &&
|
||||||
|
callExpr.callee.object.type === 'Identifier' &&
|
||||||
|
callExpr.callee.object.name === 'Result' &&
|
||||||
|
callExpr.callee.property.type === 'Identifier' &&
|
||||||
|
(callExpr.callee.property.name === 'ok' || callExpr.callee.property.name === 'err')) {
|
||||||
|
|
||||||
|
console.log(' [ReturnStatement] Result.' + callExpr.callee.property.name + '()');
|
||||||
|
|
||||||
|
if (callExpr.callee.property.name === 'ok' &&
|
||||||
|
callExpr.arguments.length > 0 &&
|
||||||
|
callExpr.arguments[0].type === 'Identifier') {
|
||||||
|
const argName = callExpr.arguments[0].name;
|
||||||
|
console.log(' [ReturnStatement] Argument:', argName);
|
||||||
|
|
||||||
|
const isApiDto = argName.includes('Dto') ||
|
||||||
|
argName.includes('api') ||
|
||||||
|
argName === 'result' ||
|
||||||
|
argName === 'data';
|
||||||
|
|
||||||
|
console.log(' [ReturnStatement] Is API DTO?', isApiDto);
|
||||||
|
console.log(' [ReturnStatement] Has builder call?', hasBuilderCall);
|
||||||
|
|
||||||
|
if (isApiDto && !hasBuilderCall) {
|
||||||
|
console.log(' [ReturnStatement] ✓ Setting manual transformation!');
|
||||||
|
hasManualTransformation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'MethodDefinition:exit'(node) {
|
||||||
|
if (node.key.type === 'Identifier' && node.key.name === 'execute') {
|
||||||
|
inPageQueryExecute = false;
|
||||||
|
console.log(' [MethodDefinition:exit] execute()');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'Program:exit'() {
|
||||||
|
console.log('\n[Program:exit]');
|
||||||
|
console.log(' hasManualTransformation:', hasManualTransformation);
|
||||||
|
console.log(' hasBuilderCall:', hasBuilderCall);
|
||||||
|
console.log(' Should report?', hasManualTransformation && !hasBuilderCall);
|
||||||
|
|
||||||
|
if (hasManualTransformation && !hasBuilderCall) {
|
||||||
|
console.log(' ✓✓✓ REPORTING ERROR ✓✓✓');
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'mustUseBuilder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test it
|
||||||
|
const mockContext = {
|
||||||
|
getSourceCode: () => ({ ast: {} }),
|
||||||
|
report: (data) => {
|
||||||
|
console.log('\n*** REPORT CALLED ***');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visitor = rule.create(mockContext);
|
||||||
|
|
||||||
|
console.log('=== Test with Logging ===\n');
|
||||||
|
|
||||||
|
visitor.ClassDeclaration({
|
||||||
|
id: { name: 'AdminDashboardPageQuery' }
|
||||||
|
});
|
||||||
|
|
||||||
|
visitor.MethodDefinition({
|
||||||
|
key: { type: 'Identifier', name: 'execute' },
|
||||||
|
parent: { type: 'ClassBody' }
|
||||||
|
});
|
||||||
|
|
||||||
|
visitor.VariableDeclarator({
|
||||||
|
init: { type: 'CallExpression' }
|
||||||
|
});
|
||||||
|
|
||||||
|
visitor.ReturnStatement({
|
||||||
|
argument: {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: { type: 'Identifier', name: 'Result' },
|
||||||
|
property: { type: 'Identifier', name: 'ok' }
|
||||||
|
},
|
||||||
|
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
visitor['MethodDefinition:exit']({
|
||||||
|
key: { type: 'Identifier', name: 'execute' }
|
||||||
|
});
|
||||||
|
|
||||||
|
visitor['Program:exit']();
|
||||||
|
|
||||||
|
console.log('\n=== Done ===');
|
||||||
63
apps/website/eslint-rules/test-rule.js
Normal file
63
apps/website/eslint-rules/test-rule.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Test script for page-query-must-use-builders rule
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rule = require('./page-query-must-use-builders.js');
|
||||||
|
const { Linter } = require('eslint');
|
||||||
|
|
||||||
|
const linter = new Linter();
|
||||||
|
|
||||||
|
// Register the plugin
|
||||||
|
linter.defineRule('gridpilot-rules/page-query-must-use-builders', rule);
|
||||||
|
|
||||||
|
const code = `
|
||||||
|
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||||
|
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||||
|
import { Result } from '@/lib/contracts/Result';
|
||||||
|
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||||
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
|
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||||
|
import type { DashboardStats } from '@/lib/api/admin/AdminApiClient';
|
||||||
|
|
||||||
|
export class AdminDashboardPageQuery implements PageQuery<DashboardStats, void> {
|
||||||
|
async execute(): Promise<Result<DashboardStats, string>> {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
|
||||||
|
const apiDto = await adminService.getDashboardStats();
|
||||||
|
|
||||||
|
return Result.ok(apiDto);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AdminDashboardPageQuery failed:', err);
|
||||||
|
|
||||||
|
if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) {
|
||||||
|
return Result.err('notFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.err('admin_dashboard_fetch_failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messages = linter.verify(code, {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'gridpilot-rules/page-query-must-use-builders': 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('ESLint messages:', messages);
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* ESLint rule to enforce View Data Builder contract
|
* ESLint rule to enforce View Data Builder contract
|
||||||
*
|
*
|
||||||
* View Data Builders must:
|
* View Data Builders must:
|
||||||
* 1. Be classes named *ViewDataBuilder
|
* 1. Be classes named *ViewDataBuilder
|
||||||
* 2. Have a static build() method
|
* 2. Have a static build() method
|
||||||
* 3. Accept an API DTO as parameter
|
* 3. Accept API DTO as parameter (named 'apiDto', NOT 'pageDto')
|
||||||
* 4. Return View Data
|
* 4. Return View Data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ module.exports = {
|
|||||||
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
|
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
|
||||||
missingBuildMethod: 'View Data Builders must have a static build() method',
|
missingBuildMethod: 'View Data Builders must have a static build() method',
|
||||||
invalidBuildSignature: 'build() method must accept API DTO and return View Data',
|
invalidBuildSignature: 'build() method must accept API DTO and return View Data',
|
||||||
|
wrongParameterName: 'Parameter must be named "apiDto", not "pageDto" or other names',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ module.exports = {
|
|||||||
|
|
||||||
let hasBuildMethod = false;
|
let hasBuildMethod = false;
|
||||||
let hasCorrectSignature = false;
|
let hasCorrectSignature = false;
|
||||||
|
let hasCorrectParameterName = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Check class declaration
|
// Check class declaration
|
||||||
@@ -47,7 +49,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for static build method
|
// Check for static build method
|
||||||
const buildMethod = node.body.body.find(member =>
|
const buildMethod = node.body.body.find(member =>
|
||||||
member.type === 'MethodDefinition' &&
|
member.type === 'MethodDefinition' &&
|
||||||
member.key.type === 'Identifier' &&
|
member.key.type === 'Identifier' &&
|
||||||
member.key.name === 'build' &&
|
member.key.name === 'build' &&
|
||||||
@@ -58,10 +60,22 @@ module.exports = {
|
|||||||
hasBuildMethod = true;
|
hasBuildMethod = true;
|
||||||
|
|
||||||
// Check signature - should have at least one parameter
|
// Check signature - should have at least one parameter
|
||||||
if (buildMethod.value &&
|
if (buildMethod.value &&
|
||||||
buildMethod.value.params &&
|
buildMethod.value.params &&
|
||||||
buildMethod.value.params.length > 0) {
|
buildMethod.value.params.length > 0) {
|
||||||
hasCorrectSignature = true;
|
hasCorrectSignature = true;
|
||||||
|
|
||||||
|
// Check parameter name
|
||||||
|
const firstParam = buildMethod.value.params[0];
|
||||||
|
if (firstParam.type === 'Identifier' && firstParam.name === 'apiDto') {
|
||||||
|
hasCorrectParameterName = true;
|
||||||
|
} else if (firstParam.type === 'Identifier' && firstParam.name === 'pageDto') {
|
||||||
|
// Report specific error for pageDto
|
||||||
|
context.report({
|
||||||
|
node: firstParam,
|
||||||
|
messageId: 'wrongParameterName',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -77,6 +91,12 @@ module.exports = {
|
|||||||
node: context.getSourceCode().ast,
|
node: context.getSourceCode().ast,
|
||||||
messageId: 'invalidBuildSignature',
|
messageId: 'invalidBuildSignature',
|
||||||
});
|
});
|
||||||
|
} else if (!hasCorrectParameterName) {
|
||||||
|
// Only report if not already reported for pageDto
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'wrongParameterName',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { Result } from "../Result";
|
|||||||
*
|
*
|
||||||
* Purpose: Framework-agnostic write operations
|
* Purpose: Framework-agnostic write operations
|
||||||
*
|
*
|
||||||
* Rules:
|
* Architecture:
|
||||||
* - Orchestrates services for writes
|
* - Server Action constructs Mutation
|
||||||
|
* - Mutation constructs Service
|
||||||
|
* - Service creates own dependencies (API Client, Logger, ErrorReporter)
|
||||||
|
* - Service returns Result<void, DomainError>
|
||||||
|
* - Mutation maps DomainError → MutationError
|
||||||
|
* - Mutation returns Result<void, MutationError>
|
||||||
* - No HTTP/API calls directly
|
* - No HTTP/API calls directly
|
||||||
* - No 'use client' directive
|
* - No 'use client' directive
|
||||||
* - No 'use server' directive
|
* - No 'use server' directive
|
||||||
@@ -18,18 +23,48 @@ import { Result } from "../Result";
|
|||||||
* Pattern:
|
* Pattern:
|
||||||
* Server Action → Mutation → Service → API Client
|
* Server Action → Mutation → Service → API Client
|
||||||
*
|
*
|
||||||
|
* Example:
|
||||||
|
* ```typescript
|
||||||
|
* export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
|
||||||
|
* async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
|
||||||
|
* const service = new UserService();
|
||||||
|
* const result = await service.updateUserStatus(input.userId, input.status);
|
||||||
|
*
|
||||||
|
* if (result.isErr()) {
|
||||||
|
* return Result.err(mapToMutationError(result.error));
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* return Result.ok(undefined);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
* Design Principle:
|
* Design Principle:
|
||||||
* Each mutation does ONE thing. If you need multiple operations,
|
* Each mutation does ONE thing. If you need multiple operations,
|
||||||
* create multiple mutation classes (e.g., UpdateUserStatusMutation, DeleteUserMutation).
|
* create multiple mutation classes (e.g., UpdateUserStatusMutation, DeleteUserMutation).
|
||||||
* This follows the same pattern as Page Queries.
|
* This follows the same pattern as Page Queries.
|
||||||
|
*
|
||||||
|
* @template TInput - The input type for the mutation
|
||||||
|
* @template TOutput - The output type on success
|
||||||
|
* @template TError - The error type (default: string for backward compatibility)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Mutation<TInput = void, TOutput = void> {
|
export interface Mutation<TInput = void, TOutput = void, TError = string> {
|
||||||
/**
|
/**
|
||||||
* Execute the mutation
|
* Execute the mutation
|
||||||
*
|
*
|
||||||
|
* Manual construction pattern:
|
||||||
|
* ```typescript
|
||||||
|
* const service = new MyService();
|
||||||
|
* const result = await service.doWrite(input);
|
||||||
|
* if (result.isErr()) {
|
||||||
|
* return Result.err(mapToMutationError(result.error));
|
||||||
|
* }
|
||||||
|
* return Result.ok(undefined);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
* @param input - Mutation input
|
* @param input - Mutation input
|
||||||
* @returns Result indicating success or error
|
* @returns Promise<Result<TOutput, TError>>
|
||||||
*/
|
*/
|
||||||
execute(input: TInput): Promise<Result<TOutput, string>>;
|
execute(input: TInput): Promise<Result<TOutput, TError>>;
|
||||||
}
|
}
|
||||||
45
apps/website/lib/contracts/mutations/MutationError.ts
Normal file
45
apps/website/lib/contracts/mutations/MutationError.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Mutation Error Type
|
||||||
|
*
|
||||||
|
* Errors that can be handled by the client after a mutation.
|
||||||
|
* These are mapped from DomainErrors by Mutations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MutationError =
|
||||||
|
| 'userNotFound' // User doesn't exist
|
||||||
|
| 'noPermission' // Insufficient permissions
|
||||||
|
| 'invalidData' // Validation failed
|
||||||
|
| 'updateFailed' // Update operation failed
|
||||||
|
| 'deleteFailed' // Delete operation failed
|
||||||
|
| 'createFailed' // Create operation failed
|
||||||
|
| 'networkError' // Network/communication error
|
||||||
|
| 'serverError' // Generic server error
|
||||||
|
| 'unknown'; // Unknown error
|
||||||
|
|
||||||
|
// Helper to map DomainError to MutationError
|
||||||
|
export function mapToMutationError(domainError: any): MutationError {
|
||||||
|
const errorType = domainError?.type || domainError?.name || 'unknown';
|
||||||
|
|
||||||
|
switch (errorType) {
|
||||||
|
case 'notFound':
|
||||||
|
case 'NotFoundError':
|
||||||
|
case 'userNotFound':
|
||||||
|
return 'userNotFound';
|
||||||
|
case 'unauthorized':
|
||||||
|
case 'UnauthorizedError':
|
||||||
|
case 'ForbiddenError':
|
||||||
|
return 'noPermission';
|
||||||
|
case 'validationError':
|
||||||
|
case 'ValidationError':
|
||||||
|
return 'invalidData';
|
||||||
|
case 'serverError':
|
||||||
|
case 'ServerError':
|
||||||
|
case 'HttpServerError':
|
||||||
|
return 'serverError';
|
||||||
|
case 'networkError':
|
||||||
|
case 'NetworkError':
|
||||||
|
return 'networkError';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,25 +2,39 @@ import { Result } from "../Result";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PageQuery contract interface
|
* PageQuery contract interface
|
||||||
*
|
*
|
||||||
* Defines the canonical contract for all server-side page queries.
|
* Defines the canonical contract for all server-side page queries.
|
||||||
*
|
*
|
||||||
* Based on WEBSITE_PAGE_QUERIES.md:
|
* Architecture:
|
||||||
* - Server-side composition classes
|
* - Server-side composition classes
|
||||||
* - Call services that call apps/api
|
* - Construct Services manually (no DI container)
|
||||||
* - Assemble a Page DTO
|
* - Services create their own dependencies (API Client, Logger, ErrorReporter)
|
||||||
* - Explicit result describing route outcome
|
* - Services return Result<ApiDto, DomainError>
|
||||||
|
* - PageQuery maps DomainError → PresentationError
|
||||||
|
* - PageQuery returns Result<ViewData, PresentationError>
|
||||||
* - Do not implement business rules
|
* - Do not implement business rules
|
||||||
*
|
*
|
||||||
* @template TApiDto - The API DTO type this query produces
|
* @template TViewData - The ViewData type this query produces for templates
|
||||||
* @template TParams - The parameters required to execute this query
|
* @template TParams - The parameters required to execute this query
|
||||||
|
* @template TError - The error type (default: string for backward compatibility)
|
||||||
*/
|
*/
|
||||||
export interface PageQuery<TApiDto, TParams = void> {
|
export interface PageQuery<TViewData, TParams = void, TError = string> {
|
||||||
/**
|
/**
|
||||||
* Execute the page query
|
* Execute the page query
|
||||||
*
|
*
|
||||||
|
* Manual construction pattern:
|
||||||
|
* ```typescript
|
||||||
|
* const service = new MyService();
|
||||||
|
* const result = await service.getData();
|
||||||
|
* if (result.isErr()) {
|
||||||
|
* return Result.err(mapToPresentationError(result.error));
|
||||||
|
* }
|
||||||
|
* const viewData = MyViewDataBuilder.build(result.value);
|
||||||
|
* return Result.ok(viewData);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
* @param params - Parameters required for query execution
|
* @param params - Parameters required for query execution
|
||||||
* @returns Promise resolving to a Result
|
* @returns Promise<Result<ViewData, TError>>
|
||||||
*/
|
*/
|
||||||
execute(params: TParams): Promise<Result<TApiDto, string>>;
|
execute(params: TParams): Promise<Result<TViewData, TError>>;
|
||||||
}
|
}
|
||||||
|
|||||||
42
apps/website/lib/contracts/page-queries/PresentationError.ts
Normal file
42
apps/website/lib/contracts/page-queries/PresentationError.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Presentation Error Type
|
||||||
|
*
|
||||||
|
* Errors that can be handled by the presentation layer (RSC pages, templates).
|
||||||
|
* These are mapped from DomainErrors by PageQueries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PresentationError =
|
||||||
|
| 'notFound' // Resource not found - show 404 page
|
||||||
|
| 'redirect' // Redirect to another page (e.g., login)
|
||||||
|
| 'unauthorized' // Not authorized - show access denied
|
||||||
|
| 'serverError' // Generic server error
|
||||||
|
| 'networkError' // Network/communication error
|
||||||
|
| 'validationError' // Invalid input data
|
||||||
|
| 'unknown'; // Unknown error
|
||||||
|
|
||||||
|
// Helper to map DomainError to PresentationError
|
||||||
|
export function mapToPresentationError(domainError: any): PresentationError {
|
||||||
|
const errorType = domainError?.type || domainError?.name || 'unknown';
|
||||||
|
|
||||||
|
switch (errorType) {
|
||||||
|
case 'notFound':
|
||||||
|
case 'NotFoundError':
|
||||||
|
return 'notFound';
|
||||||
|
case 'unauthorized':
|
||||||
|
case 'UnauthorizedError':
|
||||||
|
case 'ForbiddenError':
|
||||||
|
return 'unauthorized';
|
||||||
|
case 'serverError':
|
||||||
|
case 'ServerError':
|
||||||
|
case 'HttpServerError':
|
||||||
|
return 'serverError';
|
||||||
|
case 'networkError':
|
||||||
|
case 'NetworkError':
|
||||||
|
return 'networkError';
|
||||||
|
case 'validationError':
|
||||||
|
case 'ValidationError':
|
||||||
|
return 'validationError';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Presenter contract
|
* Presenter contract
|
||||||
*
|
*
|
||||||
|
* @deprecated Must use builders instead.
|
||||||
|
*
|
||||||
* Pure, deterministic transformation between presentation models.
|
* Pure, deterministic transformation between presentation models.
|
||||||
*
|
*
|
||||||
* Based on PRESENTERS.md:
|
* Based on PRESENTERS.md:
|
||||||
|
|||||||
@@ -1,37 +1,50 @@
|
|||||||
/**
|
/**
|
||||||
* Service contract
|
* Service Contract
|
||||||
*
|
*
|
||||||
* Orchestration boundary for server-side operations.
|
* Orchestration boundary for server-side operations.
|
||||||
* Returns API DTOs or Page DTOs only.
|
* Services are self-contained and create their own dependencies.
|
||||||
|
* Returns Result<ApiDto, DomainError> for type-safe error handling.
|
||||||
* Must be stateless.
|
* Must be stateless.
|
||||||
*
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - Services are self-contained (no constructor parameters)
|
||||||
|
* - Services create their own dependencies (API Client, Logger, ErrorReporter)
|
||||||
|
* - Services return Result<ApiDto, DomainError>
|
||||||
|
* - Services convert HTTP errors to Domain errors
|
||||||
|
*
|
||||||
* Based on WEBSITE_CONTRACT.md:
|
* Based on WEBSITE_CONTRACT.md:
|
||||||
* - Services orchestrate IO and composition
|
* - Services orchestrate IO and composition
|
||||||
* - They do not prepare UI
|
* - They do not prepare UI
|
||||||
* - They return ApiDto or PageDto only
|
* - They return Result<ApiDto, DomainError>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Result } from '@/lib/contracts/Result';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base service interface for orchestration operations
|
* Domain error type for services
|
||||||
|
* Services should define specific error types based on their domain
|
||||||
*/
|
*/
|
||||||
export interface Service<TApiDto = unknown, TPageDto = unknown> {
|
export type DomainError =
|
||||||
|
| { type: 'notFound'; message: string }
|
||||||
|
| { type: 'unauthorized'; message: string }
|
||||||
|
| { type: 'forbidden'; message: string }
|
||||||
|
| { type: 'validation'; message: string }
|
||||||
|
| { type: 'serverError'; message: string }
|
||||||
|
| { type: 'networkError'; message: string }
|
||||||
|
| { type: 'unknown'; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service interface for orchestration operations
|
||||||
|
* All service methods must return Result with domain errors
|
||||||
|
*
|
||||||
|
* Type Parameters:
|
||||||
|
* - TApiDto: The API Transport DTO type returned on success
|
||||||
|
* - TError: The domain error type (defaults to DomainError)
|
||||||
|
*/
|
||||||
|
export interface Service<TApiDto = unknown, TError extends DomainError = DomainError> {
|
||||||
/**
|
/**
|
||||||
* Execute a service operation
|
* Execute a service operation
|
||||||
* Returns either API Transport DTO or Page DTO
|
* Returns Result with API DTO or Domain Error
|
||||||
*/
|
*/
|
||||||
execute(...args: unknown[]): Promise<TApiDto | TPageDto>;
|
execute(...args: unknown[]): Promise<Result<TApiDto, TError>>;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service that returns API Transport DTOs
|
|
||||||
*/
|
|
||||||
export interface ApiService<TApiDto = unknown> extends Service<TApiDto, never> {
|
|
||||||
execute(...args: unknown[]): Promise<TApiDto>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service that returns Page DTOs
|
|
||||||
*/
|
|
||||||
export interface PageService<TPageDto = unknown> extends Service<never, TPageDto> {
|
|
||||||
execute(...args: unknown[]): Promise<TPageDto>;
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user