1. Overview
This documentation provides a comprehensive technical analysis of the Question Category and Questionnaire Index API modules within the api-questionnaires service. These modules form critical components of a questionnaire management system, handling categorization of questions and indexing of questionnaires for efficient retrieval and management.
Key Features
- Question Category Module: Manages categories for organizing questions within questionnaires
- Questionnaire Index Module: Provides indexing and retrieval mechanisms for questionnaires with support for pagination, filtering, and [access control](../Settings/Access control/AccessControlOverview.md)
Technology Stack
- Runtime: Node.js with TypeScript
- Framework: Express.js
- Database: MongoDB with Mongoose ODM
- Secondary Database: Firebase Realtime Database & Firestore
- Authentication: JWT-based authentication
- Architecture Pattern: Clean Architecture with Repository Pattern
2. Libraries and Dependencies
Core Dependencies
| Library | Version | Purpose |
|---|---|---|
express | ^4.x | Web framework for Node.js |
mongoose | ^5.x | MongoDB object modeling |
mongoose-paginate-v2 | ^1.x | Pagination support for Mongoose |
firebase-admin | ^9.x | Firebase SDK for server-side operations |
jsonwebtoken | ^8.x | JWT token generation and validation |
ramda | ^0.27.x | Functional programming utilities |
normalize-url | ^6.x | URL normalization |
Internal Dependencies
| Package | Purpose |
|---|---|
@nimbly-technologies/nimbly-backend-utils | Shared backend utilities and helpers |
@nimbly-technologies/nimbly-common | Common types and interfaces |
@nimbly-technologies/entity-node | Entity definitions and repositories |
@nimbly-technologies/nimbly-access | [Access control](../Settings/Access control/AccessControlOverview.md) and [permissions](../Settings/Access control/AccessControlOverview.md) |
Development Dependencies
| Library | Purpose |
|---|---|
typescript | TypeScript compiler |
@types/* | TypeScript type definitions |
eslint | Code linting |
jest | Testing framework |
nodemon | Development server with hot reload |
3. API Endpoints
3.1. Question Category Endpoints
| Method | Endpoint | Description | Authentication |
|---|---|---|---|
| GET | /api/question-categories/ | Search and retrieve question categories | Required |
3.2. Questionnaire Index Endpoints
| Method | Endpoint | Description | Authentication |
|---|---|---|---|
| GET | /api/questionnaire-index/ | Get all questionnaire indexes | Required |
| GET | /api/questionnaire-index/minified | Get minified questionnaire indexes | Required |
| GET | /api/questionnaire-index/active | Get active questionnaire indexes | Required |
| GET | /api/questionnaire-index/active/minified | Get active minified indexes | Required |
| GET | /api/questionnaire-index/active/no-formula/minified | Get active indexes without formulas | Required |
| GET | /api/questionnaire-index/paginate | Get paginated questionnaire indexes | Required |
| GET | /api/questionnaire-index/profile-only | Get profile-only indexes | Required |
| GET | /api/questionnaire-index/profile-only/minified | Get minified profile-only indexes | Required |
| GET | /api/questionnaire-index/:questionnaireIndexID | Get specific questionnaire index | Required |
| GET | /api/questionnaire-index/:questionnaireIndexID/latest | Get latest version of questionnaire | Required |
3.3. Questionnaire Template Endpoints
| Method | Endpoint | Description | Authentication |
|---|---|---|---|
| GET | /api/v2/questionnairetemplates/v2 | Get all accessible templates with filtering | Required |
| POST | /api/v2/questionnairetemplates/v2/create-template | Create a new template | Required |
| GET | /api/v2/questionnairetemplates/v2/:templateID | Get specific template details | Required |
| PATCH | /api/v2/questionnairetemplates/v2/update-template/:templateID | Update existing template | Required |
| DELETE | /api/v2/questionnairetemplates/v2/delete-template/:templateID | Soft delete template | Required |
| PATCH | /api/v2/questionnairetemplates/v2/restore-template/:templateID | Restore archived template | Required |
4. Module Analysis
4.1. Question Category Module
Architecture
graph LR subgraph "Question Category Module" QCR[questionCategory.router.ts] QCC[QuestionCategoryController] QCU[QuestionCategoryUsecase] QCRepo[QuestionCategoryRepository] QCS[QuestionCategorySchema] end QCR --> QCC QCC --> QCU QCU --> QCRepo QCRepo --> QCS
Component Details
Router (questionCategory.router.ts)
The router configures the HTTP endpoints for question category operations:
// Key implementation details
const router = Router();
const authMiddleware = new middlewares.AuthMiddleware(process.env.JWT_SECRET, jsonwebtoken);
const find = middlewares.expressMiddlewareHandler(questionCategoryController.find);
router.use(authMiddleware.expressHandler().bind(authMiddleware)).get('/', find);Responsibilities:
- Route definition and HTTP method mapping
- Authentication middleware application
- Request handler wrapping for error handling
Controller (QuestionCategoryController)
The controller manages request/response transformation:
public async find({ context, payload }: FindParam) {
const result = await this.questionCategoryUsecase.find(context, payload.query.search);
return response(result.data, null);
}Key Features:
- Parameter extraction from request
- Use case invocation
- Response formatting with standard structure
Use Case (QuestionCategoryUsecase)
Implements the business logic for category retrieval:
public async find(
ctx: Context<UserAuth>,
search: string,
): Promise<UsecaseReturn<Array<{ _id: string; label: string; value: string; status: string }>>> {
const query: MongoQuery<QuestionCategory> = {
organizationID: ctx.user.organizationID,
};
if (search) {
query.label = new RegExp(search, 'gi');
}
const questionCategories = await this.questionCategoryRepository.find(query);
// Deduplication logic
const uniqueCategories = Array.from(
questionCategories.reduce((map, val) => {
const sanitizedLabel = val.label.replace(/[\r\n]+/g, '');
const sanitizedTag = val.tag ? val.tag.replace(/[\r\n]+/g, ''): '';
const key = `${sanitizedLabel}-${sanitizedTag}`;
if (!map.has(key)) {
map.set(key, val);
}
return map;
}, new Map<string, typeof questionCategories[0]>())
.values(),
);
return {
data: uniqueCategories.map((val) => ({
label: val.label,
value: val.label,
_id: val._id,
status: val.status,
})),
};
}Business Logic:
- Filters by organization ID for data isolation
- Implements case-insensitive search using regex
- Removes duplicate categories based on label and tag combination
- Sanitizes labels by removing line breaks
- Returns formatted response with specific fields
Repository (QuestionCategoryRepository)
Handles database operations:
export default class QuestionCategoryRepository implements IQuestionCategoryRepository {
private model: Model<QuestionCategory & Document>;
public constructor(private conn: Connection) {
this.model = conn.model('QuestionCategory', QuestionCategorySchema) as any;
}
public async find(query: MongoQuery<QuestionCategory>) {
const builder = this.model.find(query).sort({ label: 1 });
return builder;
}
public async findPaging(query: MongoQuery<QuestionCategory>, options: PaginationOptions) {
const offset = (options.page > 0 ? options.page - 1 : 0) * options.limit;
return this.model.find(query).sort({ label: 1 }).limit(options.limit).skip(offset);
}
public async upsert(
questionCategory: QuestionCategory & { updatedAt: Date },
): Promise<QuestionCategory & Document> {
const newCategory = await this.model.findOneAndUpdate(
{ label: questionCategory.label, organizationID: questionCategory.organizationID },
{ $set: questionCategory },
{ upsert: true, returnOriginal: false },
);
return newCategory;
}
public async createMany(categories: Array<QuestionCategory>): Promise<void> {
await this.model.insertMany(categories, { rawResult: true });
}
}Features:
- Mongoose model initialization
- Sorted queries (alphabetical by label)
- Pagination support with offset calculation
- Upsert functionality for create/update operations
- Bulk insert capability
Schema (QuestionCategorySchema)
Defines the data structure:
const questionCategorySchema: MongoSchema<QuestionCategory> = {
organizationID: String,
label: String,
tag: String,
status: String,
};
const QuestionCategorySchema = new Schema(questionCategorySchema, { timestamps: true });Schema Fields:
organizationID: Organization identifier for multi-tenancylabel: Display name of the categorytag: Additional categorization tagstatus: Category status (active/inactive)- Automatic
createdAtandupdatedAttimestamps
4.2. Questionnaire Index Module
Architecture
graph TB subgraph "Questionnaire Index Module" QIR[questionnaireIndex.router.ts] QIC[QuestionnaireIndexController] QIU[QuestionnaireIndexUsecase] subgraph "Repositories" QIRepo[QuestionnaireIndexRepository] QRepo[QuestionnaireRepository] DRepo[DepartmentRepository] FRepo[FormulaRepository] RRepo[RoleRepository] end subgraph "External Services" FB[Firebase] RD[Redis] end end QIR --> QIC QIC --> QIU QIU --> QIRepo QIU --> QRepo QIU --> DRepo QIU --> FRepo QIU --> RRepo QIRepo --> FB DRepo --> RD
Component Details
Router (questionnaireIndex.router.ts)
Complex routing configuration with multiple endpoints:
const router = Router();
const authMiddleware = new middlewares.AuthMiddleware(process.env.JWT_SECRET, jsonwebtoken);
// Multiple endpoint handlers
const findAllActiveByOrganizationID = middlewares.expressMiddlewareHandler(
questionnaireIndexController.findAllActiveByOrganizationID,
);
// ... other handlers
router
.use(authMiddleware.expressHandler().bind(authMiddleware))
.get('/active', findAllActiveByOrganizationID)
.get('/active/minified', findAllActiveByOrganizationIDMinified)
.get('/active/no-formula/minified', findActiveOrganizationQuestionnaireWoutFormula)
.get('/paginate', findAllByorganizationIDPaginate)
.get('/profile-only', findAllProfileOnly)
.get('/profile-only/minified', findAllProfileOnlyMinified)
.get('/minified', findAllByorganizationIDMinified)
.get('/:questionnaireIndexID/latest', findLatestByID)
.get('/:questionnaireIndexID', findByID)
.get('/', findAllByorganizationID);Route Organization:
- Base routes for general queries
- Specialized routes for filtered data
- Parameter-based routes for specific resources
- Consistent authentication across all endpoints
Controller (QuestionnaireIndexController)
Manages multiple endpoint handlers with varied functionality:
export default class QuestionnaireIndexController {
public constructor(private questionnaireIndexUsecase: QuestionnaireIndexUsecase) {
// Bind all methods to maintain context
this.findByID = this.findByID.bind(this);
this.findLatestByID = this.findLatestByID.bind(this);
// ... other bindings
}
public async findByID({ context, payload }: FindByIDParam): Promise<FunctionReturn> {
try {
const questionnaireIndex = await this.questionnaireIndexUsecase.findByID({
context,
questionnaireIndexID: payload.params.questionnaireIndexID,
});
return response(questionnaireIndex, null);
} catch (error) {
return response(null, error);
}
}
public async findAllByorganizationIDPaginate({
context,
payload,
}: FindAllByOrganizationIDPaginateParam): Promise<FunctionReturn> {
try {
const questionnaireIndexes = await this.questionnaireIndexUsecase.findAllByOrganizationIDPaginate({
context,
paginationOptions: { ...payload.query },
queryOptions: { ...payload.query },
withDisabled: payload.query.with_disabled === 'true' ? true : false,
});
return response(questionnaireIndexes, null);
} catch (error) {
return response(null, error);
}
}
}Key Patterns:
- Consistent error handling with try-catch blocks
- Parameter transformation and validation
- Response standardization
- Method binding for proper context
Use Case (QuestionnaireIndexUsecase)
Complex business logic implementation with multiple data sources:
export default class QuestionnaireIndexUsecase {
public constructor(
private questionnaireIndexRepository: IQuestionnaireIndexRepository,
private questionnaireRepo: QuestionnaireRepository,
private deptRepo: IDepartmentRepository,
private formulaRepo: IFormulaRepository,
private roleRepo: DatabaseAccess,
) {}
public async findAllActiveByOrganizationID(context: Context<UserAuth>, props: string[]) {
let departmentIndexes = await this.deptRepo.findDepartmentIndex(context);
if (departmentIndexes?.length === 0) {
return null;
}
const allowedQuestionnaireIndexID = R.flatten(
departmentIndexes.map((x) => (x.questionnaires || []).map((y) => y.id)),
);
const questionnaireIndexQuery: MongoQuery<QuestionnaireIndex> = {
organizationID: context.user.organizationID,
questionnaireIndexID: { $in: allowedQuestionnaireIndexID },
disabled: false,
};
const questionnaireIndices = await this.questionnaireIndexRepository.find(questionnaireIndexQuery, props);
return questionnaireIndices;
}
public async findAllByOrganizationID(context: Context<UserAuth>, withDisabled = false, props: string[]) {
const query: MongoQuery<QuestionnaireIndex> = {
organizationID: context.user.organizationID,
};
if (!withDisabled) {
query.disabled = false;
}
const { userID, organizationID } = context.user;
const role = await this.roleRepo.getRoleForUser(userID, organizationID);
context.user.role = role?.origin || context.user.role;
const resourceMap = role.resources?.length
? role.resources.reduce((acc, curr) => {
acc[curr['resource']] = curr;
return acc;
}, {})
: {};
const dataVisibility = getDataVisibility(resourceMap, context.user.role);
switch (dataVisibility) {
case DataVisibility.SPECIFIC_DEPT:
case DataVisibility.SPECIFIC_SITE:
case DataVisibility.SPECIFIC_DEPT_SITE: {
const departmentIndexes = await this.deptRepo.findDepartmentIndex(context);
const allowedQuestionnaireIndexID = R.flatten(
departmentIndexes.map((x) => (x.questionnaires || []).map((y) => y.id)),
);
query.questionnaireIndexID = { $in: allowedQuestionnaireIndexID };
break;
}
default:
break;
}
const questionnaireIndex = await this.questionnaireIndexRepository.find(query, props);
return questionnaireIndex;
}
}Business Logic Features:
-
Access Control Implementation:
- Department-based access filtering
- Role-based data visibility
- Dynamic permission checking
-
Data Aggregation:
- Combines data from multiple repositories
- Flattens nested structures using Ramda
- Applies complex filtering logic
-
Performance Optimization:
- Property selection for minimal data transfer
- Early returns for empty results
- Efficient query building
Pagination Implementation
The pagination logic in findAllByOrganizationIDPaginate.ts:
export const findAllByOrganizationIDPaginate = async (
context: Context<UserAuth>,
repositories: {
questionnaireIndexRepository: IQuestionnaireIndexRepository;
departmentRepository: IDepartmentRepository;
questionnairesRepo: QuestionnaireRepository;
},
q: FindAllByOrganizationIDPaginateQueryParam,
withDepartmentAccess = false,
) => {
const query: MongoQuery<QuestionnaireIndex> = {
organizationID: context.user.organizationID,
};
if (
withDepartmentAccess &&
context.user.role !== userRoles.ACCOUNT_HOLDER &&
context.user.role !== userRoles.SUPERADMIN
) {
const departmentIndexes = await repositories.departmentRepository.findDepartmentIndex(context);
const deptQuestionnaireIndexID = flatten(
departmentIndexes.map((x) => (x.questionnaires || []).map((y) => y.id)),
);
query.questionnaireIndexID = { $in: deptQuestionnaireIndexID };
}
query.disabled = false;
ifElse(
equals(true),
() => {
query.disabled = true;
},
T,
)(q.withDisabled);
ifElse(
equals(true),
() => {
query.title = new RegExp(q.queryOptions.search, 'gi');
},
T,
)(!!q.queryOptions.search);
return repositories.questionnaireIndexRepository.findPaginate(
query,
parsePaginationOptions(q.paginationOptions),
parseSort(q.queryOptions, 'questionnaireIndexID'),
);
};Pagination Features:
- Dynamic query building based on user permissions
- Search functionality with regex
- Functional programming approach using Ramda
- Configurable sorting and filtering
Additional Response Fields Utility
The additionalResponseFields function in utils.ts:
export const additionalResponseFields = (questionnaire) => {
if (!questionnaire.hasOwnProperty('passingCriteria')) {
questionnaire.passingCriteria = null;
}
if (!questionnaire.hasOwnProperty('passingType')) {
questionnaire.passingType = null;
}
if (!questionnaire.hasOwnProperty('passingValue')) {
questionnaire.passingValue = null;
}
if (!questionnaire.hasOwnProperty('deductionToggle')) {
questionnaire.deductionToggle = false;
}
if (!questionnaire.hasOwnProperty('maxScore')) {
questionnaire.maxScore = 100;
}
if (questionnaire.questions) {
for (const question of questionnaire.questions) {
if (!question.hasOwnProperty('toggle')) {
question.toggle = [];
}
if (!question.hasOwnProperty('categoryWeights')) {
question.categoryWeights = null;
}
if (!question.hasOwnProperty('negativeScoreToggle')) {
question.negativeScoreToggle = false;
}
}
}
return questionnaire;
};Purpose: Ensures backward compatibility by adding default values for newer fields
4.3. Questionnaire Template Module
Architecture
graph TB subgraph "Questionnaire Template Module" QTR[questionnaireTemplate.router.ts] QTC[QuestionnaireTemplateController] QTU[QuestionnaireTemplateUsecase] QTRepo[QuestionnaireTemplateRepository] QTS[QuestionnaireTemplateSchema] subgraph "External Dependencies" CloudStorage[Cloud Storage] QRepo[QuestionnaireRepository] end end QTR --> QTC QTC --> QTU QTU --> QTRepo QTU --> CloudStorage QTU --> QRepo QTRepo --> QTS
Component Details
Schema (QuestionnaireTemplateSchema)
const questionnaireTemplateSchema = {
templateID: { type: String, unique: true, required: true },
templateIndexID: String, // Future: versioning support
organizationID: String, // null for default templates
type: {
type: String,
enum: ['default', 'custom'],
required: true
},
title: { type: String, required: true },
description: String,
questions: [{
// Same structure as questionnaire questions
content: String,
type: String,
category: String,
answerRequired: Boolean,
// ... all question fields
}],
visibility: {
type: String,
enum: ['organization', 'public'],
default: 'organization'
},
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft'
},
createdBy: String,
modifiedBy: String,
modifiedByName: String, // Display name for UI
version: String
};Use Case (QuestionnaireTemplateUsecase)
Key business logic implementations:
public async getAllTemplates(ctx: Context<UserAuth>, queryParams?: TemplateQueryParams) {
const { organizationID } = ctx.user;
// Build query for organization templates + default templates
const query = {
$or: [
{ organizationID, type: 'custom' },
{ type: 'default', status: 'published' }
]
};
// Apply filters if provided
if (queryParams?.search) {
query.$and = [{
$or: [
{ title: new RegExp(queryParams.search, 'i') },
{ description: new RegExp(queryParams.search, 'i') }
]
}];
}
if (queryParams?.type) {
const types = queryParams.type.split(',');
query.type = { $in: types };
}
if (queryParams?.status) {
const statuses = queryParams.status.split(',');
query.status = { $in: statuses };
}
// Handle pagination
if (queryParams?.page || queryParams?.limit) {
return this.repository.findPaginated(query, {
page: queryParams.page || 1,
limit: queryParams.limit || 10,
sort: { [queryParams.sortBy || 'createdAt']: queryParams.sortOrder || -1 }
});
}
// Return array for backward compatibility
return this.repository.find(query);
}
public async createTemplate(ctx: Context<UserAuth>, data: CreateTemplateDto) {
const template = {
templateID: uuidv4(),
organizationID: ctx.user.organizationID,
type: data.type || 'custom',
title: data.title,
description: data.description,
questions: data.questions,
status: data.publish ? 'published' : 'draft',
visibility: data.type === 'default' ? 'public' : 'organization',
createdBy: ctx.user.userID,
modifiedBy: ctx.user.userID
};
// Validate template
if (!template.questions || template.questions.length === 0) {
throw new Error('Template must have at least one question');
}
return this.repository.create(template);
}
public async updateTemplate(ctx: Context<UserAuth>, templateID: string, updates: UpdateTemplateDto) {
const template = await this.repository.findById(templateID);
// Authorization check
if (template.type === 'custom' && template.organizationID !== ctx.user.organizationID) {
throw new Error('Unauthorized access to template');
}
// Prevent updates to archived templates
if (template.status === 'archived') {
throw new Error('Cannot update archived template');
}
// Handle publish flag
if (updates.publish && template.status === 'draft') {
updates.status = 'published';
}
updates.modifiedBy = ctx.user.userID;
updates.modifiedByName = ctx.user.displayName;
return this.repository.update(templateID, updates);
}Controller (QuestionnaireTemplateController)
public async getAllTemplates({ context, payload }: GetAllTemplatesParam) {
try {
const queryParams = payload.query;
const result = await this.usecase.getAllTemplates(context, queryParams);
// Add updatedBy display name for UI
if (Array.isArray(result)) {
result.forEach(template => {
template.updatedBy = template.modifiedByName || 'Unknown';
});
} else if (result.data) {
result.data.forEach(template => {
template.updatedBy = template.modifiedByName || 'Unknown';
});
}
return response(result, null);
} catch (error) {
return response(null, error);
}
}API Request/Response Examples
Get Templates with Filtering
GET /api/v2/questionnairetemplates/v2?search=safety&type=custom&status=published&page=1&limit=20
Response:
{
"data": {
"data": [
{
"templateID": "550e8400-e29b-41d4-a716-446655440000",
"title": "Safety Inspection Template",
"type": "custom",
"status": "published",
"description": "Template for daily safety inspections",
"updatedBy": "John Smith",
"createdAt": "2024-01-15T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 45,
"totalPages": 3
}
},
"error": null,
"type": "json"
}Create Template
POST /api/v2/questionnairetemplates/v2/create-template
{
"title": "Equipment Inspection",
"description": "Standard equipment check",
"publish": true,
"questions": [
{
"content": "Is equipment operational?",
"type": "binary",
"category": "Equipment",
"answerRequired": true
}
]
}Business Rules
-
Template Types:
default: Created by Nimbly, available to all orgscustom: Organization-specific templates
-
Access Control:
- Users see their org’s templates + published default templates
- Only custom templates can be edited/deleted by org users
- Default templates are read-only
-
Status Workflow:
draft → published → archived ↑ ↓ ←─── restore ──────── -
Validation:
- Title is required
- At least one question required
- Cannot update archived templates
- Questions follow same validation as regular questionnaires
Note: For complete template API documentation including all endpoints, request/response examples, and implementation details, refer to
@api-questionnaires/QUESTIONNAIRE_TEMPLATE_API.mdin the api-questionnaires repository.
5. Data Flow Architecture
5.1. Question Category Search Flow
sequenceDiagram participant Client participant AuthMiddleware participant Controller participant UseCase participant Repository participant MongoDB Client->>AuthMiddleware: GET /api/question-categories/?search=safety AuthMiddleware->>AuthMiddleware: Verify JWT token AuthMiddleware->>Controller: Authenticated request with user context Controller->>UseCase: find(context, "safety") UseCase->>UseCase: Build query with organizationID UseCase->>UseCase: Add search regex if provided UseCase->>Repository: find(query) Repository->>MongoDB: db.questioncategories.find({...}).sort({label: 1}) MongoDB-->>Repository: Category documents Repository-->>UseCase: Raw categories array UseCase->>UseCase: Deduplicate by label+tag UseCase->>UseCase: Format response data UseCase-->>Controller: UsecaseReturn with formatted data Controller->>Controller: Wrap in standard response Controller-->>Client: HTTP 200 with categories
5.2. Questionnaire Index Retrieval Flow (with Access Control)
sequenceDiagram participant Client participant AuthMiddleware participant Controller participant UseCase participant RoleRepo participant DeptRepo participant QIRepo participant Redis Client->>AuthMiddleware: GET /api/questionnaire-index/ AuthMiddleware->>Controller: Authenticated request Controller->>UseCase: findAllByOrganizationID(context, withDisabled, props) UseCase->>RoleRepo: getRoleForUser(userID, organizationID) RoleRepo-->>UseCase: User role and permissions UseCase->>UseCase: Calculate data visibility alt Restricted Access UseCase->>DeptRepo: findDepartmentIndex(context) DeptRepo->>Redis: Check cache Redis-->>DeptRepo: Cached data or null DeptRepo-->>UseCase: Department indexes UseCase->>UseCase: Extract allowed questionnaire IDs end UseCase->>QIRepo: find(query, props) QIRepo-->>UseCase: Questionnaire indexes UseCase-->>Controller: Filtered results Controller-->>Client: HTTP 200 with data
5.3. Paginated Query Flow
flowchart TB Start([Client Request]) --> Parse[Parse Query Parameters] Parse --> BuildQuery[Build Base Query] BuildQuery --> CheckRole{Check User Role} CheckRole -->|Admin| NoFilter[No Department Filter] CheckRole -->|Regular User| DeptFilter[Apply Department Filter] DeptFilter --> GetDepts[Get Department Indexes] GetDepts --> FilterQI[Filter Questionnaire IDs] NoFilter --> ApplyOptions[Apply Query Options] FilterQI --> ApplyOptions ApplyOptions --> Search{Has Search Term?} Search -->|Yes| AddRegex[Add Title Regex] Search -->|No| Skip1[Continue] AddRegex --> Disabled{Include Disabled?} Skip1 --> Disabled Disabled -->|Yes| SetDisabled[Set disabled=true] Disabled -->|No| SetEnabled[Set disabled=false] SetDisabled --> Paginate[Execute Paginated Query] SetEnabled --> Paginate Paginate --> Format[Format Response] Format --> Return([Return to Client])
6. Implementation Details
6.1. Authentication and Authorization
The authentication system uses JWT tokens with a middleware layer:
const authMiddleware = new middlewares.AuthMiddleware(process.env.JWT_SECRET, jsonwebtoken);Features:
- JWT token validation
- User context extraction
- Organization-based data isolation
- Role-based access control
6.2. Error Handling Strategy
Controllers implement consistent error handling:
try {
// Business logic
return response(data, null);
} catch (error) {
return response(null, error);
}Response Helper Function:
// helpers/controllerResponse.ts
export default function response(data: any, error: any) {
return {
data: data,
error: error
};
}6.3. Database Connection Management
The application uses MongoDB with Mongoose for primary data storage:
// Repository initialization
public constructor(private conn: Connection) {
this.model = conn.model('QuestionCategory', QuestionCategorySchema) as any;
}Connection Features:
- Shared connection instance
- Model caching
- Schema versioning with timestamps
6.4. Caching Strategy
Redis is used for caching department indexes:
const deptIndexCache = initRedis<DepartmentIndex[]>({
host: redisHost,
port: Number(redisPort),
password: redisPassword,
prefix: 'department_index_user:',
});Cache Benefits:
- Reduced database load
- Faster department access checks
- User-specific cache keys
6.5. Multi-tenancy Implementation
Organization-based data isolation is enforced at multiple levels:
- Query Level: All queries include
organizationID - Schema Level: Organization ID is a required field
- Access Control: Department-based filtering
- Cache Keys: Organization-specific prefixes
6.6. Performance Optimizations
-
Projection Support:
const questionnaireIndices = await this.questionnaireIndexRepository.find(query, props);Allows selecting only required fields
-
Pagination: Limits result set size for large collections
-
Indexing: Sorted queries utilize database indexes
-
Deduplication: In-memory deduplication reduces response size
6.7. Security Considerations
-
Input Validation:
- Regex patterns are sanitized
- Query parameters are typed and validated
-
Data Isolation:
- Organization-based filtering
- Department-based access control
-
Authentication:
- JWT token validation
- Token expiration handling
-
Authorization:
- Role-based permissions
- Resource-level access control
7. Database Schemas
7.1. Question Category Schema
interface QuestionCategory {
organizationID: string; // Multi-tenancy identifier
label: string; // Display name
tag?: string; // Optional categorization
status: string; // Active/Inactive status
createdAt?: Date; // Auto-generated
updatedAt?: Date; // Auto-generated
}Indexes:
- Compound index on
(organizationID, label) - Text index on
labelfor search
7.2. Questionnaire Index Schema
interface QuestionnaireIndex {
questionnaireIndexID: string;
organizationID: string;
title: string;
disabled: boolean;
latest: string; // Reference to latest questionnaire version
populated?: {
latest: Questionnaire;
};
}Relationships:
- One-to-many with Questionnaires
- Many-to-many with Departments
- One-to-many with Formulas
8. Error Handling
8.1. Error Types
-
Authentication Errors:
- Invalid or expired JWT tokens
- Missing authentication headers
-
Authorization Errors:
- Insufficient permissions
- Department access violations
-
Validation Errors:
- Invalid query parameters
- Schema validation failures
-
Database Errors:
- Connection failures
- Query timeouts
- Duplicate key violations
8.2. Error Response Format
{
"data": null,
"error": {
"message": "Error description",
"code": "ERROR_CODE",
"details": {}
}
}8.3. Error Handling Best Practices
-
Graceful Degradation: Return empty results instead of failing for missing departments
-
Error Logging: Console logging for debugging in development
-
User-Friendly Messages: Transform technical errors into actionable messages
-
Consistent Format: All errors follow the same response structure
9. Conclusion
The Question Category and Questionnaire Index APIs demonstrate a well-architected system following clean architecture principles. The implementation provides:
- Robust Architecture: Clear separation of concerns with layered architecture
- Scalability: Designed for horizontal scaling with caching strategies
- Security: Multi-level security with authentication and authorization
- Performance: Optimized queries with pagination and projection support
- Maintainability: Clean code structure with TypeScript for type safety
The system effectively handles multi-tenant data isolation while providing flexible query capabilities and maintaining high performance through strategic caching and database optimization.
This documentation serves as a comprehensive guide for developers working with these APIs, providing both high-level architecture understanding and detailed implementation insights.