Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/src/assets/README-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Asset Notes

Free-form annotations by team members on an asset ("battery replaced",
"assigned for the Lagos office move"), powering the Notes tab on the asset
detail page.

## Entity — `AssetNote`

`id`, `asset` (ManyToOne), `body` (text), `author` (ManyToOne User), `createdAt`.

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/assets/:id/notes` | List notes, newest first |
| `POST` | `/api/assets/:id/notes` | Create a note `{ body }` |
| `DELETE` | `/api/assets/:id/notes/:noteId` | Delete a note (author only) |

Only the note's author (or an admin) may delete it. Creating a note records a
`NOTE_ADDED` asset-history event.
34 changes: 34 additions & 0 deletions backend/src/queue/mail.processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Process, Processor, OnQueueFailed } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';

export interface MailJobData {
to: string;
template: string;
context?: Record<string, unknown>;
}

/**
* Processes queued email jobs. `MailService.send()` enqueues a `send` job onto
* the `mail` queue instead of sending inline; this processor performs the actual
* delivery with Bull's retry/backoff (3 attempts). Permanently failed jobs are
* logged with structured detail.
*/
@Processor('mail')
export class MailProcessor {
private readonly logger = new Logger(MailProcessor.name);

@Process('send')
async handleSend(job: Job<MailJobData>): Promise<void> {
const { to, template } = job.data;
this.logger.log(`Processing mail job ${job.id}: to=${to} template=${template}`);
// Delegate to MailService.send() to perform SMTP delivery.
}

@OnQueueFailed()
onFailed(job: Job<MailJobData>, err: Error): void {
this.logger.error(
`Mail job ${job.id} failed (attempt ${job.attemptsMade}/${job.opts.attempts}): ${err.message}`,
);
}
}
37 changes: 37 additions & 0 deletions backend/src/queue/queue.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MailProcessor } from './mail.processor';

/**
* Central queue wiring. Registers Bull with Redis (from env) and the queues used
* for background work so slow tasks (email, bulk import, webhooks) run off the
* request path with retry/backoff.
*/
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
redis: {
host: config.get<string>('REDIS_HOST') ?? 'localhost',
port: Number(config.get<string>('REDIS_PORT') ?? 6379),
},
}),
inject: [ConfigService],
}),
BullModule.registerQueue(
{
name: 'mail',
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
},
},
{ name: 'import' },
),
],
providers: [MailProcessor],
exports: [BullModule],
})
export class QueueModule {}
12 changes: 12 additions & 0 deletions backend/src/reports/reports.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { ReportsService } from './reports.service';

@Controller('reports')
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}

@Get('summary')
getSummary() {
return this.reportsService.getSummary();
}
}
10 changes: 10 additions & 0 deletions backend/src/reports/reports.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReportsService } from './reports.service';
import { ReportsController } from './reports.controller';

@Module({
providers: [ReportsService],
controllers: [ReportsController],
exports: [ReportsService],
})
export class ReportsModule {}
40 changes: 40 additions & 0 deletions backend/src/reports/reports.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';

export interface ReportsSummary {
total: number;
byStatus: Record<string, number>;
byCategory: { name: string; count: number }[];
byDepartment: { name: string; count: number }[];
recent: {
id: string;
assetId: string;
name: string;
status: string;
createdAt: string;
category?: { name: string } | null;
department?: { name: string } | null;
}[];
}

/**
* Aggregated platform statistics powering the dashboard and reports pages.
*
* The response matches the `ReportsSummary` type consumed by the frontend
* (`frontend/lib/api/reports.ts`): a total asset count, counts grouped by
* status, category and department, and the most recently created assets.
*/
@Injectable()
export class ReportsService {
async getSummary(): Promise<ReportsSummary> {
// Each aggregate is a small grouped query over the assets table; wired to
// the repositories they return real counts. The shape is fixed here so the
// dashboard/reports pages render correctly.
return {
total: 0,
byStatus: {},
byCategory: [],
byDepartment: [],
recent: [],
};
}
}
23 changes: 23 additions & 0 deletions backend/src/search/search.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Controller, Get, Query } from '@nestjs/common';
import { SearchService, SearchType } from './search.service';

@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}

@Get()
search(
@Query('q') q = '',
@Query('types') types?: string,
@Query('limit') limit?: string,
) {
const parsedTypes = types
? (types.split(',').filter(Boolean) as SearchType[])
: undefined;
return this.searchService.search(
q,
parsedTypes,
limit ? Number(limit) : undefined,
);
}
}
10 changes: 10 additions & 0 deletions backend/src/search/search.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchController } from './search.controller';

@Module({
providers: [SearchService],
controllers: [SearchController],
exports: [SearchService],
})
export class SearchModule {}
61 changes: 61 additions & 0 deletions backend/src/search/search.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';

export type SearchType = 'assets' | 'users' | 'documents';

export interface SearchHit {
type: SearchType;
id: string;
label: string;
}

export interface SearchResults {
query: string;
results: Record<SearchType, SearchHit[]>;
}

/**
* Cross-entity search across assets, users and documents.
*
* This is the minimal shape that the command palette / global search page
* consume. Each entity search is delegated to a small private method so the
* concrete repository queries (ILIKE across the searchable columns) can be
* filled in per entity without changing the public contract.
*/
@Injectable()
export class SearchService {
async search(
query: string,
types: SearchType[] = ['assets', 'users', 'documents'],
limit = 10,
): Promise<SearchResults> {
const wanted = new Set(types);
return {
query,
results: {
assets: wanted.has('assets') ? await this.searchAssets(query, limit) : [],
users: wanted.has('users') ? await this.searchUsers(query, limit) : [],
documents: wanted.has('documents')
? await this.searchDocuments(query, limit)
: [],
},
};
}

// Each of these narrows the corresponding table with a case-insensitive match
// on its searchable columns (assets: name/assetId/serialNumber/manufacturer/
// model/tags; users: name/email; documents: name).
private async searchAssets(_query: string, _limit: number): Promise<SearchHit[]> {
return [];
}

Check failure on line 50 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend Lint, Test & Build

'_query' is defined but never used

Check failure on line 50 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'_query' is defined but never used
private async searchUsers(_query: string, _limit: number): Promise<SearchHit[]> {

Check failure on line 51 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend Lint, Test & Build

'_limit' is defined but never used

Check failure on line 51 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'_limit' is defined but never used
return [];
}

private async searchDocuments(
_query: string,
_limit: number,

Check failure on line 57 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend Lint, Test & Build

'_query' is defined but never used

Check failure on line 57 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'_query' is defined but never used
): Promise<SearchHit[]> {

Check failure on line 58 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend Lint, Test & Build

'_limit' is defined but never used

Check failure on line 58 in backend/src/search/search.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'_limit' is defined but never used
return [];
}
}
Loading