Admin Page work - Still blank/broken

This commit is contained in:
Eric Gullickson
2025-11-06 16:29:11 -06:00
parent 858cf31d38
commit 5630979adf
38 changed files with 7373 additions and 924 deletions

View File

@@ -0,0 +1,227 @@
# Bulk Catalog Delete Endpoint Documentation
## Overview
Generic bulk delete endpoint for catalog entities (makes, models, years, trims, engines) in the admin panel.
## Endpoint
```
DELETE /api/admin/catalog/{entity}/bulk-delete
```
## Path Parameters
- `entity`: Entity type - one of: `makes`, `models`, `years`, `trims`, `engines`
## Request Body
```json
{
"ids": [1, 2, 3, 4, 5]
}
```
### Validation Rules
- IDs must be an array of positive integers
- At least 1 ID required
- Maximum 100 IDs per batch
- All IDs must be valid integers (not strings or floats)
## Response Codes
- `204 No Content`: All deletions succeeded (no response body)
- `207 Multi-Status`: Some deletions failed (includes response body with details)
- `400 Bad Request`: Invalid entity type or invalid request body
- `401 Unauthorized`: Missing or invalid authentication
- `500 Internal Server Error`: Unexpected server error
## Response Body (207 Multi-Status only)
```json
{
"deleted": [1, 3, 5],
"failed": [
{
"id": 2,
"error": "Make 2 not found"
},
{
"id": 4,
"error": "Cannot delete make with existing models"
}
]
}
```
## Cascade Behavior
The endpoint uses existing single-delete methods which have the following behavior:
### Makes
- **Blocks deletion** if models exist under the make
- Error: "Cannot delete make with existing models"
- **Solution**: Delete all dependent models first
### Models
- **Blocks deletion** if years exist under the model
- Error: "Cannot delete model with existing years"
- **Solution**: Delete all dependent years first
### Years
- **Blocks deletion** if trims exist under the year
- Error: "Cannot delete year with existing trims"
- **Solution**: Delete all dependent trims first
### Trims
- **Blocks deletion** if engines exist under the trim
- Error: "Cannot delete trim with existing engines"
- **Solution**: Delete all dependent engines first
### Engines
- **No cascade restrictions** (leaf entity in hierarchy)
## Deletion Order for Hierarchy
To delete an entire make and all its dependencies:
1. Delete engines first
2. Delete trims
3. Delete years
4. Delete models
5. Delete make last
## Examples
### Example 1: Delete Multiple Engines (Success)
```bash
DELETE /api/admin/catalog/engines/bulk-delete
{
"ids": [101, 102, 103]
}
Response: 204 No Content
```
### Example 2: Delete Multiple Makes (Partial Failure)
```bash
DELETE /api/admin/catalog/makes/bulk-delete
{
"ids": [1, 2, 3]
}
Response: 207 Multi-Status
{
"deleted": [3],
"failed": [
{
"id": 1,
"error": "Cannot delete make with existing models"
},
{
"id": 2,
"error": "Make 2 not found"
}
]
}
```
### Example 3: Invalid Entity Type
```bash
DELETE /api/admin/catalog/invalid/bulk-delete
{
"ids": [1, 2, 3]
}
Response: 400 Bad Request
{
"error": "Invalid entity type",
"message": "Entity must be one of: makes, models, years, trims, engines"
}
```
### Example 4: Invalid IDs
```bash
DELETE /api/admin/catalog/makes/bulk-delete
{
"ids": ["abc", "def"]
}
Response: 400 Bad Request
{
"error": "Invalid IDs",
"message": "All IDs must be positive integers"
}
```
## Implementation Details
### Files Modified
1. `/backend/src/features/admin/api/admin.routes.ts` (line 209-212)
- Added route: `DELETE /admin/catalog/:entity/bulk-delete`
- Requires admin authentication
2. `/backend/src/features/admin/api/catalog.controller.ts` (line 542-638)
- Added method: `bulkDeleteCatalogEntity()`
- Maps entity type to appropriate delete method
- Processes deletions sequentially
- Collects successes and failures
3. `/backend/src/features/admin/api/admin.validation.ts` (line 43-49, 57-58)
- Added `catalogEntitySchema`: Validates entity type
- Added `bulkDeleteCatalogSchema`: Validates request body
- Exported types: `CatalogEntity`, `BulkDeleteCatalogInput`
4. `/backend/src/features/admin/domain/admin.types.ts` (line 97-103)
- Added `BulkDeleteCatalogResponse` interface
### Continue-on-Failure Pattern
The endpoint uses a continue-on-failure pattern:
- One deletion failure does NOT stop the batch
- All deletions are attempted
- Successes and failures are tracked separately
- Final response includes both lists
### Transaction Behavior
- Each individual deletion runs in its own transaction (via service layer)
- If one delete fails, it doesn't affect others
- No rollback of previously successful deletions
## Testing
### Manual Testing with cURL
```bash
# Test valid request (requires auth token)
curl -X DELETE "http://localhost/api/admin/catalog/makes/bulk-delete" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{"ids": [1, 2, 3]}'
# Test invalid entity type
curl -X DELETE "http://localhost/api/admin/catalog/invalid/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [1, 2, 3]}'
# Test empty IDs
curl -X DELETE "http://localhost/api/admin/catalog/makes/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": []}'
```
### Expected Audit Log Behavior
Each successful deletion creates a platform change log entry:
- `changeType`: "DELETE"
- `resourceType`: Entity type (makes, models, years, trims, engines)
- `resourceId`: ID of deleted entity
- `changedBy`: Actor's user ID
- `oldValue`: Entity data before deletion
- `newValue`: null
## Security
- Endpoint requires admin authentication (via `fastify.requireAdmin`)
- Actor ID is logged for all operations
- All deletions are audited in platform_change_log table
## Performance Considerations
- Deletions are processed sequentially (not in parallel)
- Each deletion queries the database separately
- Cache invalidation occurs after each successful deletion
- For large batches (50+ items), consider breaking into smaller batches
## Future Enhancements
Potential improvements:
1. Add cascade delete option to automatically delete dependent entities
2. Add dry-run mode to preview what would be deleted
3. Add batch size optimization for better performance
4. Add progress tracking for long-running batches

View File

@@ -11,13 +11,25 @@ import { logger } from '../../../core/logging/logger';
import { import {
CreateAdminInput, CreateAdminInput,
AdminAuth0SubInput, AdminAuth0SubInput,
AuditLogsQueryInput AuditLogsQueryInput,
BulkCreateAdminInput,
BulkRevokeAdminInput,
BulkReinstateAdminInput
} from './admin.validation'; } from './admin.validation';
import { import {
createAdminSchema, createAdminSchema,
adminAuth0SubSchema, adminAuth0SubSchema,
auditLogsQuerySchema auditLogsQuerySchema,
bulkCreateAdminSchema,
bulkRevokeAdminSchema,
bulkReinstateAdminSchema
} from './admin.validation'; } from './admin.validation';
import {
BulkCreateAdminResponse,
BulkRevokeAdminResponse,
BulkReinstateAdminResponse,
AdminUser
} from '../domain/admin.types';
export class AdminController { export class AdminController {
private adminService: AdminService; private adminService: AdminService;
@@ -398,6 +410,260 @@ export class AdminController {
} }
} }
/**
* POST /api/admin/admins/bulk - Create multiple admin users
*/
async bulkCreateAdmins(
request: FastifyRequest<{ Body: BulkCreateAdminInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate request body
const validation = bulkCreateAdminSchema.safeParse(request.body);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: validation.error.errors
});
}
const { admins } = validation.data;
const created: AdminUser[] = [];
const failed: Array<{ email: string; error: string }> = [];
// Process each admin creation sequentially to maintain data consistency
for (const adminInput of admins) {
try {
const { email, role = 'admin' } = adminInput;
// Generate auth0Sub for the new admin
// In production, this should be the actual Auth0 user ID
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
const admin = await this.adminService.createAdmin(
email,
role,
auth0Sub,
actorId
);
created.push(admin);
} catch (error: any) {
logger.error('Error creating admin in bulk operation', {
error: error.message,
email: adminInput.email,
actorId
});
failed.push({
email: adminInput.email,
error: error.message || 'Failed to create admin'
});
}
}
const response: BulkCreateAdminResponse = {
created,
failed
};
// Return 207 Multi-Status if there were any failures, 201 if all succeeded
const statusCode = failed.length > 0 ? 207 : 201;
return reply.code(statusCode).send(response);
} catch (error: any) {
logger.error('Error in bulk create admins', {
error: error.message,
actorId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to process bulk admin creation'
});
}
}
/**
* PATCH /api/admin/admins/bulk-revoke - Revoke multiple admin users
*/
async bulkRevokeAdmins(
request: FastifyRequest<{ Body: BulkRevokeAdminInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate request body
const validation = bulkRevokeAdminSchema.safeParse(request.body);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: validation.error.errors
});
}
const { auth0Subs } = validation.data;
const revoked: AdminUser[] = [];
const failed: Array<{ auth0Sub: string; error: string }> = [];
// Process each revocation sequentially to maintain data consistency
for (const auth0Sub of auth0Subs) {
try {
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
if (!targetAdmin) {
failed.push({
auth0Sub,
error: 'Admin user not found'
});
continue;
}
// Attempt to revoke the admin
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId);
revoked.push(admin);
} catch (error: any) {
logger.error('Error revoking admin in bulk operation', {
error: error.message,
auth0Sub,
actorId
});
// Special handling for "last admin" constraint
failed.push({
auth0Sub,
error: error.message || 'Failed to revoke admin'
});
}
}
const response: BulkRevokeAdminResponse = {
revoked,
failed
};
// Return 207 Multi-Status if there were any failures, 200 if all succeeded
const statusCode = failed.length > 0 ? 207 : 200;
return reply.code(statusCode).send(response);
} catch (error: any) {
logger.error('Error in bulk revoke admins', {
error: error.message,
actorId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to process bulk admin revocation'
});
}
}
/**
* PATCH /api/admin/admins/bulk-reinstate - Reinstate multiple revoked admin users
*/
async bulkReinstateAdmins(
request: FastifyRequest<{ Body: BulkReinstateAdminInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate request body
const validation = bulkReinstateAdminSchema.safeParse(request.body);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: validation.error.errors
});
}
const { auth0Subs } = validation.data;
const reinstated: AdminUser[] = [];
const failed: Array<{ auth0Sub: string; error: string }> = [];
// Process each reinstatement sequentially to maintain data consistency
for (const auth0Sub of auth0Subs) {
try {
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
if (!targetAdmin) {
failed.push({
auth0Sub,
error: 'Admin user not found'
});
continue;
}
// Attempt to reinstate the admin
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId);
reinstated.push(admin);
} catch (error: any) {
logger.error('Error reinstating admin in bulk operation', {
error: error.message,
auth0Sub,
actorId
});
failed.push({
auth0Sub,
error: error.message || 'Failed to reinstate admin'
});
}
}
const response: BulkReinstateAdminResponse = {
reinstated,
failed
};
// Return 207 Multi-Status if there were any failures, 200 if all succeeded
const statusCode = failed.length > 0 ? 207 : 200;
return reply.code(statusCode).send(response);
} catch (error: any) {
logger.error('Error in bulk reinstate admins', {
error: error.message,
actorId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to process bulk admin reinstatement'
});
}
}
private resolveUserEmail(request: FastifyRequest): string | undefined { private resolveUserEmail(request: FastifyRequest): string | undefined {
console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2)); console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2));
console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2)); console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2));

View File

@@ -8,7 +8,12 @@ import { AdminController } from './admin.controller';
import { import {
CreateAdminInput, CreateAdminInput,
AdminAuth0SubInput, AdminAuth0SubInput,
AuditLogsQueryInput AuditLogsQueryInput,
BulkCreateAdminInput,
BulkRevokeAdminInput,
BulkReinstateAdminInput,
BulkDeleteCatalogInput,
CatalogEntity
} from './admin.validation'; } from './admin.validation';
import { AdminRepository } from '../data/admin.repository'; import { AdminRepository } from '../data/admin.repository';
import { StationOversightService } from '../domain/station-oversight.service'; import { StationOversightService } from '../domain/station-oversight.service';
@@ -69,6 +74,24 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
handler: adminController.getAuditLogs.bind(adminController) handler: adminController.getAuditLogs.bind(adminController)
}); });
// POST /api/admin/admins/bulk - Create multiple admins
fastify.post<{ Body: BulkCreateAdminInput }>('/admin/admins/bulk', {
preHandler: [fastify.requireAdmin],
handler: adminController.bulkCreateAdmins.bind(adminController)
});
// PATCH /api/admin/admins/bulk-revoke - Revoke multiple admins
fastify.patch<{ Body: BulkRevokeAdminInput }>('/admin/admins/bulk-revoke', {
preHandler: [fastify.requireAdmin],
handler: adminController.bulkRevokeAdmins.bind(adminController)
});
// PATCH /api/admin/admins/bulk-reinstate - Reinstate multiple admins
fastify.patch<{ Body: BulkReinstateAdminInput }>('/admin/admins/bulk-reinstate', {
preHandler: [fastify.requireAdmin],
handler: adminController.bulkReinstateAdmins.bind(adminController)
});
// Phase 3: Catalog CRUD endpoints // Phase 3: Catalog CRUD endpoints
// Makes endpoints // Makes endpoints
@@ -182,6 +205,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
handler: catalogController.getChangeLogs.bind(catalogController) handler: catalogController.getChangeLogs.bind(catalogController)
}); });
// Bulk delete endpoint
fastify.delete<{ Params: { entity: CatalogEntity }; Body: BulkDeleteCatalogInput }>('/admin/catalog/:entity/bulk-delete', {
preHandler: [fastify.requireAdmin],
handler: catalogController.bulkDeleteCatalogEntity.bind(catalogController)
});
// Phase 4: Station oversight endpoints // Phase 4: Station oversight endpoints
// GET /api/admin/stations - List all stations globally // GET /api/admin/stations - List all stations globally

View File

@@ -19,6 +19,40 @@ export const auditLogsQuerySchema = z.object({
offset: z.coerce.number().min(0).default(0), offset: z.coerce.number().min(0).default(0),
}); });
export const bulkCreateAdminSchema = z.object({
admins: z.array(
z.object({
email: z.string().email('Invalid email format'),
role: z.enum(['admin', 'super_admin']).optional().default('admin'),
})
).min(1, 'At least one admin must be provided').max(100, 'Maximum 100 admins per batch'),
});
export const bulkRevokeAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
.min(1, 'At least one auth0Sub must be provided')
.max(100, 'Maximum 100 admins per batch'),
});
export const bulkReinstateAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
.min(1, 'At least one auth0Sub must be provided')
.max(100, 'Maximum 100 admins per batch'),
});
export const catalogEntitySchema = z.enum(['makes', 'models', 'years', 'trims', 'engines']);
export const bulkDeleteCatalogSchema = z.object({
ids: z.array(z.number().int().positive('ID must be a positive integer'))
.min(1, 'At least one ID must be provided')
.max(100, 'Maximum 100 items per batch'),
});
export type CreateAdminInput = z.infer<typeof createAdminSchema>; export type CreateAdminInput = z.infer<typeof createAdminSchema>;
export type AdminAuth0SubInput = z.infer<typeof adminAuth0SubSchema>; export type AdminAuth0SubInput = z.infer<typeof adminAuth0SubSchema>;
export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>; export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;
export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>;
export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>;
export type BulkReinstateAdminInput = z.infer<typeof bulkReinstateAdminSchema>;
export type CatalogEntity = z.infer<typeof catalogEntitySchema>;
export type BulkDeleteCatalogInput = z.infer<typeof bulkDeleteCatalogSchema>;

View File

@@ -536,4 +536,103 @@ export class CatalogController {
reply.code(500).send({ error: 'Failed to retrieve change logs' }); reply.code(500).send({ error: 'Failed to retrieve change logs' });
} }
} }
// BULK DELETE ENDPOINT
async bulkDeleteCatalogEntity(
request: FastifyRequest<{ Params: { entity: string }; Body: { ids: number[] } }>,
reply: FastifyReply
): Promise<void> {
try {
const { entity } = request.params;
const { ids } = request.body;
const actorId = request.userContext?.userId || 'unknown';
// Validate entity type
const validEntities = ['makes', 'models', 'years', 'trims', 'engines'];
if (!validEntities.includes(entity)) {
reply.code(400).send({
error: 'Invalid entity type',
message: `Entity must be one of: ${validEntities.join(', ')}`
});
return;
}
// Validate IDs are provided
if (!ids || !Array.isArray(ids) || ids.length === 0) {
reply.code(400).send({
error: 'Invalid request',
message: 'At least one ID must be provided'
});
return;
}
// Validate all IDs are valid integers
const invalidIds = ids.filter(id => !Number.isInteger(id) || id <= 0);
if (invalidIds.length > 0) {
reply.code(400).send({
error: 'Invalid IDs',
message: 'All IDs must be positive integers'
});
return;
}
const deleted: number[] = [];
const failed: Array<{ id: number; error: string }> = [];
// Map entity to delete method
const deleteMethodMap: Record<string, (id: number, actorId: string) => Promise<void>> = {
makes: (id, actor) => this.catalogService.deleteMake(id, actor),
models: (id, actor) => this.catalogService.deleteModel(id, actor),
years: (id, actor) => this.catalogService.deleteYear(id, actor),
trims: (id, actor) => this.catalogService.deleteTrim(id, actor),
engines: (id, actor) => this.catalogService.deleteEngine(id, actor)
};
const deleteMethod = deleteMethodMap[entity];
// Process each deletion sequentially to maintain data consistency
for (const id of ids) {
try {
await deleteMethod(id, actorId);
deleted.push(id);
} catch (error: any) {
logger.error(`Error deleting ${entity} in bulk operation`, {
error: error.message,
entity,
id,
actorId
});
failed.push({
id,
error: error.message || `Failed to delete ${entity}`
});
}
}
const response = {
deleted,
failed
};
// Return 207 Multi-Status if there were any failures, 204 if all succeeded
if (failed.length > 0) {
reply.code(207).send(response);
} else {
reply.code(204).send();
}
} catch (error: any) {
logger.error('Error in bulk delete catalog entity', {
error: error.message,
entity: request.params.entity,
actorId: request.userContext?.userId
});
reply.code(500).send({
error: 'Internal server error',
message: 'Failed to process bulk deletion'
});
}
}
} }

View File

@@ -53,3 +53,51 @@ export interface AdminAuditResponse {
total: number; total: number;
logs: AdminAuditLog[]; logs: AdminAuditLog[];
} }
// Batch operation types
export interface BulkCreateAdminRequest {
admins: Array<{
email: string;
role?: 'admin' | 'super_admin';
}>;
}
export interface BulkCreateAdminResponse {
created: AdminUser[];
failed: Array<{
email: string;
error: string;
}>;
}
export interface BulkRevokeAdminRequest {
auth0Subs: string[];
}
export interface BulkRevokeAdminResponse {
revoked: AdminUser[];
failed: Array<{
auth0Sub: string;
error: string;
}>;
}
export interface BulkReinstateAdminRequest {
auth0Subs: string[];
}
export interface BulkReinstateAdminResponse {
reinstated: AdminUser[];
failed: Array<{
auth0Sub: string;
error: string;
}>;
}
export interface BulkDeleteCatalogResponse {
deleted: number[];
failed: Array<{
id: number;
error: string;
}>;
}

View File

@@ -1,343 +0,0 @@
# Admin Feature Deployment Checklist
Production deployment checklist for the Admin feature (Phases 1-5 complete).
## Pre-Deployment Verification (Phase 6)
### Code Quality Gates
- [ ] **TypeScript compilation**: `npm run build` - Zero errors
- [ ] **Linting**: `npm run lint` - Zero warnings
- [ ] **Backend tests**: `npm test -- features/admin` - All passing
- [ ] **Frontend tests**: `npm test` - All passing
- [ ] **Container builds**: `make rebuild` - Success
- [ ] **Backend startup**: `make start` - Server running on port 3001
- [ ] **Health checks**: `curl https://motovaultpro.com/api/health` - 200 OK
- [ ] **Frontend build**: Vite build completes in <20 seconds
- [ ] **No deprecated code**: All old code related to admin removed
- [ ] **Documentation complete**: ADMIN.md, feature READMEs updated
### Security Verification
- [ ] **Parameterized queries**: Grep confirms no SQL concatenation in admin feature
- [ ] **Input validation**: All endpoints validate with Zod schemas
- [ ] **HTTPS only**: Verify Traefik configured for HTTPS
- [ ] **Auth0 integration**: Dev/prod Auth0 domains match configuration
- [ ] **JWT validation**: Token verification working in auth plugin
- [ ] **Admin guard**: `fastify.requireAdmin` blocking non-admins with 403
- [ ] **Audit logging**: All admin actions logged to database
- [ ] **Last admin protection**: Confirmed system cannot revoke last admin
### Database Verification
- [ ] **Migrations exist**: Both migration files present
- `backend/src/features/admin/migrations/001_create_admin_users.sql`
- `backend/src/features/admin/migrations/002_create_platform_change_log.sql`
- [ ] **Tables created**: Run migrations verify
```bash
docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \
"\dt admin_users admin_audit_logs platform_change_log"
```
- [ ] **Initial admin seeded**: Verify bootstrap admin exists
```bash
docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \
"SELECT email, role, revoked_at FROM admin_users WHERE auth0_sub = 'system|bootstrap';"
```
- [ ] **Indexes created**: Verify all indexes exist
```bash
docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \
"SELECT tablename, indexname FROM pg_indexes WHERE tablename IN ('admin_users', 'admin_audit_logs', 'platform_change_log');"
```
- [ ] **Foreign keys configured**: Cascade rules work correctly
- [ ] **Backup tested**: Database backup includes new tables
### API Verification
#### Phase 2 Endpoints (Admin Management)
- [ ] **GET /api/admin/admins** - Returns all admins
```bash
curl -H "Authorization: Bearer $JWT" https://motovaultpro.com/api/admin/admins
```
- [ ] **POST /api/admin/admins** - Creates admin (with valid email)
- [ ] **PATCH /api/admin/admins/:auth0Sub/revoke** - Revokes admin
- [ ] **PATCH /api/admin/admins/:auth0Sub/reinstate** - Reinstates admin
- [ ] **GET /api/admin/audit-logs** - Returns audit trail
- [ ] **403 Forbidden** - Non-admin user blocked from all endpoints
#### Phase 3 Endpoints (Catalog CRUD)
- [ ] **GET /api/admin/catalog/makes** - List makes
- [ ] **POST /api/admin/catalog/makes** - Create make
- [ ] **PUT /api/admin/catalog/makes/:makeId** - Update make
- [ ] **DELETE /api/admin/catalog/makes/:makeId** - Delete make
- [ ] **GET /api/admin/catalog/change-logs** - View change history
- [ ] **Cache invalidation**: Redis keys flushed after mutations
- [ ] **Transaction support**: Failed mutations rollback cleanly
#### Phase 4 Endpoints (Station Oversight)
- [ ] **GET /api/admin/stations** - List all stations
- [ ] **POST /api/admin/stations** - Create station
- [ ] **PUT /api/admin/stations/:stationId** - Update station
- [ ] **DELETE /api/admin/stations/:stationId** - Soft delete (default)
- [ ] **DELETE /api/admin/stations/:stationId?force=true** - Hard delete
- [ ] **GET /api/admin/users/:userId/stations** - User's saved stations
- [ ] **DELETE /api/admin/users/:userId/stations/:stationId** - Remove user station
- [ ] **Cache invalidation**: `mvp:stations:*` keys flushed
### Frontend Verification (Mobile + Desktop)
#### Desktop Verification
- [ ] **Admin console visible** - SettingsPage shows "Admin Console" card when admin
- [ ] **Non-admin message** - Non-admin users see "Not authorized" message
- [ ] **Navigation links work** - Admin/Users, Admin/Catalog, Admin/Stations accessible
- [ ] **Admin pages load** - Route guards working, 403 page for non-admins
- [ ] **useAdminAccess hook** - Loading state shows spinner while checking admin status
#### Mobile Verification (375px viewport)
- [ ] **Admin section visible** - MobileSettingsScreen shows admin section when admin
- [ ] **Admin section hidden** - Completely hidden for non-admin users
- [ ] **Touch targets** - All buttons are ≥44px height
- [ ] **Mobile pages load** - Routes accessible on mobile
- [ ] **No layout issues** - Text readable, buttons tappable on 375px screen
- [ ] **Loading states** - Proper spinner on admin data loads
#### Responsive Design
- [ ] **Desktop 1920px** - All pages display correctly
- [ ] **Mobile 375px** - All pages responsive, no horizontal scroll
- [ ] **Tablet 768px** - Intermediate sizing works
- [ ] **No console errors** - Check browser DevTools
- [ ] **Performance acceptable** - Page load <3s on mobile
### Integration Testing
- [ ] **End-to-end workflow**:
1. Login as admin
2. Navigate to admin console
3. Create new admin user
4. Verify audit log entry
5. Revoke new admin
6. Verify last admin protection prevents revocation of only remaining admin
7. Create catalog item
8. Verify cache invalidation
9. Create station
10. Verify soft/hard delete behavior
- [ ] **Error handling**:
- [ ] 400 Bad Request - Invalid input (test with malformed JSON)
- [ ] 403 Forbidden - Non-admin access attempt
- [ ] 404 Not Found - Nonexistent resource
- [ ] 409 Conflict - Referential integrity violation
- [ ] 500 Internal Server Error - Database connection failure
- [ ] **Audit trail verification**:
- [ ] All admin management actions logged
- [ ] All catalog mutations recorded with old/new values
- [ ] All station operations tracked
- [ ] Actor admin ID correctly stored
### Performance Verification
- [ ] **Query performance**: Admin list returns <100ms (verify in logs)
- [ ] **Large dataset handling**: Test with 1000+ audit logs
- [ ] **Cache efficiency**: Repeated queries use cache
- [ ] **No N+1 queries**: Verify in query logs
- [ ] **Pagination works**: Limit/offset parameters functioning
### Monitoring & Logging
- [ ] **Admin logs visible**: `make logs | grep -i admin` shows entries
- [ ] **Audit trail stored**: `SELECT COUNT(*) FROM admin_audit_logs;` > 0
- [ ] **Error logging**: Failed operations logged with context
- [ ] **Performance metrics**: Slow queries logged
### Documentation
- [ ] **ADMIN.md complete**: All endpoints documented
- [ ] **API examples provided**: Sample requests/responses included
- [ ] **Security notes documented**: Input validation, parameterized queries explained
- [ ] **Deployment section**: Clear instructions for operators
- [ ] **Troubleshooting guide**: Common issues and solutions
- [ ] **Backend feature README**: Phase descriptions, extending guide
- [ ] **docs/README.md updated**: Admin references added
## Deployment Steps
### 1. Pre-Deployment
```bash
# Verify all tests pass
npm test -- features/admin
docker compose exec mvp-frontend npm test
# Verify builds succeed
make rebuild
# Backup database
./scripts/backup-database.sh
# Verify rollback plan documented
cat docs/ADMIN.md | grep -A 20 "## Rollback"
```
### 2. Database Migration
```bash
# Run migrations (automatic on container startup, or manual)
docker compose exec mvp-backend npm run migrate
# Verify tables and seed data
docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \
"SELECT COUNT(*) FROM admin_users; SELECT COUNT(*) FROM admin_audit_logs;"
```
### 3. Container Deployment
```bash
# Stop current containers
docker compose down
# Pull latest code
git pull origin main
# Rebuild containers with latest code
make rebuild
# Start services
make start
# Verify health
make logs | grep -i "Backend is healthy"
curl https://motovaultpro.com/api/health
```
### 4. Post-Deployment Verification
```bash
# Verify health endpoints
curl https://motovaultpro.com/api/health | jq .features
# Test admin endpoint (with valid JWT)
curl -H "Authorization: Bearer $JWT" \
https://motovaultpro.com/api/admin/admins | jq .total
# Verify frontend loads
curl -s https://motovaultpro.com | grep -q "motovaultpro" && echo "Frontend OK"
# Check logs for errors
make logs | grep -i error | head -20
```
### 5. Smoke Tests (Manual)
1. **Desktop**:
- Visit https://motovaultpro.com
- Login with admin account
- Navigate to Settings
- Verify "Admin Console" card visible
- Click "User Management"
- Verify admin list loads
2. **Mobile**:
- Open https://motovaultpro.com on mobile device or dev tools (375px)
- Login with admin account
- Navigate to Settings
- Verify admin section visible
- Tap "Users"
- Verify admin list loads
3. **Non-Admin**:
- Login with non-admin account
- Navigate to `/garage/settings/admin/users`
- Verify 403 Forbidden page displayed
- Check that admin console NOT visible on settings page
## Rollback Procedure
If critical issues found after deployment:
```bash
# 1. Revert code to previous version
git revert HEAD
docker compose down
make rebuild
make start
# 2. If database schema issue, restore from backup
./scripts/restore-database.sh backup-timestamp.sql
# 3. Verify health
curl https://motovaultpro.com/api/health
# 4. Test rollback endpoints
curl -H "Authorization: Bearer $JWT" \
https://motovaultpro.com/api/vehicles/
# 5. Monitor logs for 30 minutes
make logs | tail -f
```
## Supported Browsers
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
## Known Limitations
- Admin feature requires JavaScript enabled
- Mobile UI optimized for portrait orientation
- Catalog changes may take 5 minutes to propagate in cache
## Sign-Off
- [ ] **Tech Lead**: All quality gates passed ______________________ Date: _______
- [ ] **QA**: End-to-end testing complete ______________________ Date: _______
- [ ] **DevOps**: Deployment procedure verified ______________________ Date: _______
- [ ] **Product**: Feature acceptance confirmed ______________________ Date: _______
## Post-Deployment Monitoring
Monitor for 24 hours:
- [ ] Health check endpoint responding
- [ ] No 500 errors in logs
- [ ] Admin operations completing <500ms
- [ ] No database connection errors
- [ ] Memory usage stable
- [ ] Disk space adequate
- [ ] All feature endpoints responding
## Release Notes
```markdown
## Admin Feature (v1.0)
### New Features
- Admin role and access control (Phase 1)
- Admin user management with audit trail (Phase 2)
- Vehicle catalog CRUD operations (Phase 3)
- Gas station oversight and management (Phase 4)
- Admin UI for desktop and mobile (Phase 5)
### Breaking Changes
None
### Migration Required
Yes - Run `npm run migrate` automatically on container startup
### Rollback Available
Yes - See ADMIN-DEPLOYMENT-CHECKLIST.md
### Documentation
See `docs/ADMIN.md` for complete reference
```

View File

@@ -1,440 +0,0 @@
# Admin Feature Implementation Summary
Complete implementation of the admin feature for MotoVaultPro across all 6 phases.
## Executive Summary
Successfully implemented a complete admin role management system with cross-tenant CRUD authority for platform catalog and station management. All phases completed in parallel, with comprehensive testing, documentation, and deployment procedures.
**Status:** PRODUCTION READY
## Implementation Overview
### Phase 1: Access Control Foundations ✅ COMPLETE
**Deliverables:**
- `backend/src/features/admin/` - Feature capsule directory structure
- `001_create_admin_users.sql` - Database schema for admin users and audit logs
- `admin.types.ts` - TypeScript type definitions
- `admin.repository.ts` - Data access layer with parameterized queries
- `admin-guard.plugin.ts` - Fastify authorization plugin
- Enhanced auth plugin with `request.userContext`
**Key Features:**
- Admin user tracking with `auth0_sub` primary key
- Admin audit logs for all actions
- Last admin protection (cannot revoke last active admin)
- Soft-delete via `revoked_at` timestamp
- All queries parameterized (no SQL injection risk)
**Status:** Verified in containers - database tables created and seeded
### Phase 2: Admin Management APIs ✅ COMPLETE
**Endpoints Implemented:** 5
1. `GET /api/admin/admins` - List all admin users (active and revoked)
2. `POST /api/admin/admins` - Create new admin (with validation)
3. `PATCH /api/admin/admins/:auth0Sub/revoke` - Revoke admin access (prevents last admin revocation)
4. `PATCH /api/admin/admins/:auth0Sub/reinstate` - Restore revoked admin
5. `GET /api/admin/audit-logs` - Retrieve audit trail (paginated)
**Implementation Files:**
- `admin.controller.ts` - HTTP request handlers
- `admin.validation.ts` - Zod input validation schemas
- Integration tests - Full API endpoint coverage
**Security:**
- All endpoints require `fastify.requireAdmin` guard
- Input validation on all endpoints (email format, role enum, required fields)
- Audit logging on all actions
- Last admin protection prevents system lockout
### Phase 3: Platform Catalog CRUD ✅ COMPLETE
**Endpoints Implemented:** 21
- **Makes**: GET, POST, PUT, DELETE (4 endpoints)
- **Models**: GET (by make), POST, PUT, DELETE (4 endpoints)
- **Years**: GET (by model), POST, PUT, DELETE (4 endpoints)
- **Trims**: GET (by year), POST, PUT, DELETE (4 endpoints)
- **Engines**: GET (by trim), POST, PUT, DELETE (4 endpoints)
- **Change Logs**: GET with pagination (1 endpoint)
**Implementation Files:**
- `vehicle-catalog.service.ts` - Service layer with transaction support
- `catalog.controller.ts` - HTTP handlers for all catalog operations
- `002_create_platform_change_log.sql` - Audit log table for catalog changes
**Key Features:**
- Transaction support - All mutations wrapped in BEGIN/COMMIT/ROLLBACK
- Cache invalidation - `platform:*` Redis keys flushed on mutations
- Referential integrity - Prevents orphan deletions
- Change history - All mutations logged with old/new values
- Complete audit trail - Who made what changes and when
### Phase 4: Station Oversight ✅ COMPLETE
**Endpoints Implemented:** 6
1. `GET /api/admin/stations` - List all stations (with pagination and search)
2. `POST /api/admin/stations` - Create new station
3. `PUT /api/admin/stations/:stationId` - Update station
4. `DELETE /api/admin/stations/:stationId` - Delete station (soft or hard)
5. `GET /api/admin/users/:userId/stations` - List user's saved stations
6. `DELETE /api/admin/users/:userId/stations/:stationId` - Remove user station (soft or hard)
**Implementation Files:**
- `station-oversight.service.ts` - Service layer for station operations
- `stations.controller.ts` - HTTP handlers
**Key Features:**
- Soft delete by default (sets `deleted_at` timestamp)
- Hard delete with `?force=true` query parameter
- Cache invalidation - `mvp:stations:*` and `mvp:stations:saved:{userId}` keys
- Pagination support - `limit` and `offset` query parameters
- Search support - `?search=query` filters stations
- Audit logging - All mutations tracked
### Phase 5: UI Integration (Frontend) ✅ COMPLETE
**Mobile + Desktop Implementation - BOTH REQUIRED**
**Components Created:**
**Desktop Pages:**
- `AdminUsersPage.tsx` - Manage admin users
- `AdminCatalogPage.tsx` - Manage vehicle catalog
- `AdminStationsPage.tsx` - Manage gas stations
**Mobile Screens (separate implementations):**
- `AdminUsersMobileScreen.tsx` - Mobile user management
- `AdminCatalogMobileScreen.tsx` - Mobile catalog management
- `AdminStationsMobileScreen.tsx` - Mobile station management
**Core Infrastructure:**
- `useAdminAccess.ts` hook - Verify admin status (loading, error, not-admin states)
- `useAdmins.ts` - React Query hooks for admin CRUD
- `useCatalog.ts` - React Query hooks for catalog operations
- `useStationOverview.ts` - React Query hooks for station management
- `admin.api.ts` - API client functions
- `admin.types.ts` - TypeScript types mirroring backend
**Integration:**
- Settings page updated with "Admin Console" card (desktop)
- MobileSettingsScreen updated with admin section (mobile)
- Routes added to App.tsx with admin guards
- Route guards verify `useAdminAccess` before allowing access
**Responsive Design:**
- Desktop: 1920px viewport - Full MUI components
- Mobile: 375px viewport - Touch-optimized GlassCard pattern
- Separate implementations (not responsive components)
- Touch targets ≥44px on mobile
- No horizontal scroll on mobile
### Phase 6: Quality Gates & Documentation ✅ COMPLETE
**Documentation Created:**
1. **docs/ADMIN.md** - Comprehensive feature documentation
- Architecture overview
- Database schema reference
- Complete API reference with examples
- Authorization rules and security considerations
- Deployment procedures
- Troubleshooting guide
- Performance monitoring
2. **docs/ADMIN-DEPLOYMENT-CHECKLIST.md** - Production deployment guide
- Pre-deployment verification (80+ checkpoints)
- Code quality gates verification
- Security verification
- Database verification
- API endpoint testing procedures
- Frontend verification (mobile + desktop)
- Integration testing procedures
- Performance testing
- Post-deployment monitoring
- Rollback procedures
- Sign-off sections
3. **docs/ADMIN-IMPLEMENTATION-SUMMARY.md** - This document
- Overview of all 6 phases
- Files created/modified
- Verification results
- Risk assessment
- Next steps
**Documentation Updates:**
- Updated `docs/README.md` with admin references
- Updated `backend/src/features/admin/README.md` with completion status
- Updated health check endpoint to include admin feature
**Code Quality:**
- TypeScript compilation: ✅ Successful (containers build without errors)
- Linting: ✅ Verified (no style violations)
- Container builds: ✅ Successful (multi-stage Docker build passes)
- Backend startup: ✅ Running on port 3001
- Health checks: ✅ Returning 200 with features list including 'admin'
- Redis connectivity: ✅ Connected and working
- Database migrations: ✅ All 3 admin tables created
- Initial seed: ✅ Bootstrap admin seeded (admin@motovaultpro.com)
## File Summary
### Backend Files Created (30+ files)
**Core:**
- `backend/src/features/admin/api/admin.controller.ts`
- `backend/src/features/admin/api/admin.validation.ts`
- `backend/src/features/admin/api/admin.routes.ts`
- `backend/src/features/admin/api/catalog.controller.ts`
- `backend/src/features/admin/api/stations.controller.ts`
**Domain:**
- `backend/src/features/admin/domain/admin.types.ts`
- `backend/src/features/admin/domain/admin.service.ts`
- `backend/src/features/admin/domain/vehicle-catalog.service.ts`
- `backend/src/features/admin/domain/station-oversight.service.ts`
**Data:**
- `backend/src/features/admin/data/admin.repository.ts`
**Migrations:**
- `backend/src/features/admin/migrations/001_create_admin_users.sql`
- `backend/src/features/admin/migrations/002_create_platform_change_log.sql`
**Tests:**
- `backend/src/features/admin/tests/unit/admin.guard.test.ts`
- `backend/src/features/admin/tests/unit/admin.service.test.ts`
- `backend/src/features/admin/tests/integration/admin.integration.test.ts`
- `backend/src/features/admin/tests/integration/catalog.integration.test.ts`
- `backend/src/features/admin/tests/integration/stations.integration.test.ts`
**Core Plugins:**
- `backend/src/core/plugins/admin-guard.plugin.ts`
- Enhanced: `backend/src/core/plugins/auth.plugin.ts`
**Configuration:**
- Updated: `backend/src/app.ts` (admin plugin registration, route registration, health checks)
- Updated: `backend/src/_system/migrations/run-all.ts` (added admin to migration order)
### Frontend Files Created (15+ files)
**Types & API:**
- `frontend/src/features/admin/types/admin.types.ts`
- `frontend/src/features/admin/api/admin.api.ts`
**Hooks:**
- `frontend/src/core/auth/useAdminAccess.ts`
- `frontend/src/features/admin/hooks/useAdmins.ts`
- `frontend/src/features/admin/hooks/useCatalog.ts`
- `frontend/src/features/admin/hooks/useStationOverview.ts`
**Pages (Desktop):**
- `frontend/src/pages/admin/AdminUsersPage.tsx`
- `frontend/src/pages/admin/AdminCatalogPage.tsx`
- `frontend/src/pages/admin/AdminStationsPage.tsx`
**Screens (Mobile):**
- `frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx`
- `frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx`
- `frontend/src/features/admin/mobile/AdminStationsMobileScreen.tsx`
**Tests:**
- `frontend/src/features/admin/__tests__/useAdminAccess.test.ts`
- `frontend/src/features/admin/__tests__/useAdmins.test.ts`
- `frontend/src/features/admin/__tests__/AdminUsersPage.test.tsx`
**UI Integration:**
- Updated: `frontend/src/pages/SettingsPage.tsx` (admin console card)
- Updated: `frontend/src/features/settings/mobile/MobileSettingsScreen.tsx` (admin section)
- Updated: `frontend/src/App.tsx` (admin routes and guards)
### Documentation Files Created
- `docs/ADMIN.md` - Comprehensive reference (400+ lines)
- `docs/ADMIN-DEPLOYMENT-CHECKLIST.md` - Deployment guide (500+ lines)
- `docs/ADMIN-IMPLEMENTATION-SUMMARY.md` - This summary
### Documentation Files Updated
- `docs/README.md` - Added admin references
- `backend/src/features/admin/README.md` - Completed phase descriptions
## Database Verification
### Tables Created ✅
```
admin_users (admin_audit_logs, platform_change_log also created)
```
**admin_users:**
```
email | role | revoked_at
------------------------+-------+------------
admin@motovaultpro.com | admin | (null)
(1 row)
```
**Indexes verified:**
- `idx_admin_users_email` - For lookups
- `idx_admin_users_created_at` - For audit trails
- `idx_admin_users_revoked_at` - For active admin queries
- All platform_change_log indexes created
**Triggers verified:**
- `update_admin_users_updated_at` - Auto-update timestamp
## Backend Verification
### Health Endpoint ✅
```
GET /api/health → 200 OK
Features: [admin, vehicles, documents, fuel-logs, stations, maintenance, platform]
Status: healthy
Redis: connected
```
### Migrations ✅
```
✅ features/admin/001_create_admin_users.sql - Completed
✅ features/admin/002_create_platform_change_log.sql - Skipped (already executed)
✅ All migrations completed successfully
```
### Container Status ✅
- Backend running on port 3001
- Configuration loaded successfully
- Redis connected
- Database migrations orchestrated correctly
## Remaining Tasks & Risks
### Low Priority (Future Phases)
1. **Full CRUD UI implementation** - Admin pages currently have route stubs, full forms needed
2. **Role-based permissions** - Extend from binary admin to granular roles
3. **2FA for admins** - Enhanced security requirement
4. **Bulk import/export** - Catalog data management improvements
5. **Advanced analytics** - Admin activity dashboards
### Known Limitations
- Admin feature requires JavaScript enabled
- Mobile UI optimized for portrait orientation (landscape partially supported)
- Catalog changes may take 5 minutes to propagate in cache (configurable)
### Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|-----------|
| Last admin revoked (system lockout) | Low | Critical | Business logic prevents this |
| SQL injection | Very Low | Critical | All queries parameterized |
| Unauthorized admin access | Low | High | Guard plugin on all routes |
| Cache consistency | Medium | Medium | Redis invalidation on mutations |
| Migration order issue | Low | High | Explicit MIGRATION_ORDER array |
## Deployment Readiness Checklist
- ✅ All 5 phases implemented
- ✅ Code compiles without errors
- ✅ Containers build successfully
- ✅ Migrations run correctly
- ✅ Database schema verified
- ✅ Backend health checks passing
- ✅ Admin guard working (verified in logs)
- ✅ Comprehensive documentation created
- ✅ Deployment checklist prepared
- ✅ Rollback procedures documented
- ⚠️ Integration tests created (require test runner setup)
- ⚠️ E2E tests created (manual verification needed)
## Quick Start for Developers
### Running the Admin Feature
```bash
# Build and start containers
make rebuild
make start
# Verify health
curl https://motovaultpro.com/api/health | jq .features
# Test admin endpoint (requires valid JWT)
curl -H "Authorization: Bearer $JWT" \
https://motovaultpro.com/api/admin/admins
# Check logs
make logs | grep admin
```
### Using the Admin UI
**Desktop:**
1. Navigate to https://motovaultpro.com
2. Login with admin account
3. Go to Settings
4. Click "Admin Console" card
**Mobile:**
1. Navigate to https://motovaultpro.com on mobile (375px)
2. Login with admin account
3. Go to Settings
4. Tap "Admin" section
5. Select operation (Users, Catalog, Stations)
### Default Admin Credentials
- **Email:** admin@motovaultpro.com
- **Auth0 ID:** system|bootstrap
- **Role:** admin
- **Status:** Active (not revoked)
## Performance Baselines
- Health check: <5ms
- List admins: <100ms
- Create admin: <200ms
- List stations: <500ms (1000+ records)
- Catalog CRUD: <300ms per operation
## References
- **Architecture:** `docs/PLATFORM-SERVICES.md`
- **API Reference:** `docs/ADMIN.md`
- **Deployment Guide:** `docs/ADMIN-DEPLOYMENT-CHECKLIST.md`
- **Backend Feature:** `backend/src/features/admin/README.md`
- **Testing Guide:** `docs/TESTING.md`
- **Security:** `docs/SECURITY.md`
## Sign-Off
| Role | Approval | Date | Notes |
|------|----------|------|-------|
| Implementation | ✅ Complete | 2025-11-05 | All 6 phases done |
| Code Quality | ✅ Verified | 2025-11-05 | Builds, migrations run, health OK |
| Documentation | ✅ Complete | 2025-11-05 | ADMIN.md, deployment checklist |
| Security | ✅ Reviewed | 2025-11-05 | Parameterized queries, guards |
| Testing | ✅ Created | 2025-11-05 | Unit, integration, E2E test files |
## Next Steps
1. **Immediate:** Run full deployment checklist before production deployment
2. **Testing:** Execute integration and E2E tests in test environment
3. **Validation:** Smoke test on staging environment (desktop + mobile)
4. **Rollout:** Deploy to production following ADMIN-DEPLOYMENT-CHECKLIST.md
5. **Monitoring:** Monitor for 24 hours post-deployment
6. **Future:** Implement UI refinements and additional features (role-based permissions, 2FA)
---
**Implementation Date:** 2025-11-05
**Status:** PRODUCTION READY
**Version:** 1.0.0

View File

@@ -1,45 +0,0 @@
# Admin Role & UI Implementation Plan
Context: extend MotoVaultPro with an administrative user model, cross-tenant CRUD authority, and surfaced controls within the existing settings experience. Follow phases in order; each phase is shippable and assumes Docker-based validation per `CLAUDE.md`.
## Phase 1 Access Control Foundations
- Create `backend/src/features/admin/` capsule scaffolding (api/, domain/, data/, migrations/, tests/).
- Add migration `001_create_admin_users.sql` for table `admin_users (auth0_sub PK, email, created_at, created_by, revoked_at)`.
- Seed first record (`admin@motorvaultpro.com`, `created_by = system`) via migration or bootstrap script.
- Extend auth plugin flow to hydrate `request.userContext` containing `userId`, `email`, `isAdmin`, `adminRecord`.
- Add reusable guard `authorizeAdmin` in `backend/src/core/middleware/admin-guard.ts`; return 403 with `{ error: 'Forbidden', message: 'Admin access required' }`.
- Unit tests: guard behavior, context resolver, seed idempotency.
## Phase 2 Admin Management APIs
- Implement `/api/admin/admins` controller with list/add/revoke/reinstate endpoints; enforce “at least one active admin” rule in repository.
- Add audit logging via existing `logger` (log `actorAdminId`, `targetAdminId`, `action`, `context`).
- Provide read-only `/api/admin/users` for user summaries (reusing existing repositories, no data mutation yet).
- Integration tests validating: guard rejects non-admins, add admin, revoke admin while preventing last admin removal.
## Phase 3 Platform Catalog CRUD
- Add service `vehicleCatalog.service.ts` under admin feature to manage `vehicles.make|model|model_year|trim|engine|trim_engine`.
- Expose `/api/admin/catalog/...` endpoints for hierarchical CRUD; wrap mutations in transactions with referential validation.
- On write, call new cache helper in `backend/src/features/platform/domain/platform-cache.service.ts` to invalidate keys `platform:*`.
- Record admin change history in table `platform_change_log` (migration `002_create_platform_change_log.sql`).
- Tests: unit (service + cache invalidation), integration (create/update/delete + redis key flush assertions).
## Phase 4 Station Oversight
- Implement `/api/admin/stations` for global station CRUD and `/api/admin/users/:userId/stations` to manage saved stations.
- Ensure mutations update `stations` and `saved_stations` tables with soft delete semantics and invalidation of `stations:saved:{userId}` plus cached search keys.
- Provide optional `force=true` query to hard delete (document usage, default soft delete).
- Tests covering cache busting, permission enforcement, and happy-path CRUD.
## Phase 5 UI Integration (Settings-Based)
- Create hook `frontend/src/core/auth/useAdminAccess.ts` that calls `/auth/verify`, caches `isAdmin`, handles loading/error states.
- Desktop: update `frontend/src/pages/SettingsPage.tsx` to inject an “Admin Console” card when `isAdmin` true (links to admin subroutes) and display access CTA otherwise.
- Mobile: add admin section to `frontend/src/features/settings/mobile/MobileSettingsScreen.tsx` using existing `GlassCard` pattern; hide entirely for non-admins.
- Route stubs (e.g. `/garage/settings/admin/*`) should lazy-load forthcoming admin dashboards; guard them with `useAdminAccess`.
- Frontend tests (Jest/RTL) verifying conditional rendering on admin vs non-admin contexts.
## Phase 6 Quality Gates & Documentation
- Run backend/ frontend lint + tests inside containers (`make rebuild`, `make logs`, `make test-backend`, `docker compose exec mvp-frontend npm test`).
- Author `docs/ADMIN.md` summarizing role management workflow, API catalog, cache rules, and operational safeguards.
- Update existing docs (`docs/PLATFORM-SERVICES.md`, `docs/VEHICLES-API.md`, `docs/GAS-STATIONS.md`, `docs/README.md`) with admin references.
- Prepare release checklist: database migration order, seed verification for initial admin, smoke tests on both device classes (mobile + desktop), rollback notes.
- Confirm Traefik `/auth/verify` headers expose admin flag where needed for downstream services.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
# Admin Settings Frontend Implementation Plan
## Audience & Scope
- **Intended executor**: AI agent implementing MotoVaultPro admin settings UI across desktop and mobile.
- **Scope**: Frontend-only tasks within `frontend/`, coordinating with existing backend admin APIs. Includes real-time audit log integration and bulk operations across admin users, catalog entities, and station management.
## Current State Summary
- Routes exist (`frontend/src/pages/admin/*.tsx`, `frontend/src/features/admin/mobile/*.tsx`) but contain placeholder copy.
- Hooks and API clients (`frontend/src/features/admin/hooks/*`, `frontend/src/features/admin/api/admin.api.ts`) already wrap CRUD endpoints but lack bulk helpers and streaming.
- Settings pages link into admin routes; `useAdminAccess` gate is wired.
- No shared admin layout, tables, or selection utilities; no real-time audit consumption; no bulk UI.
## Key Requirements
1. **Real-time audit logging** for admin operations (desktop + mobile).
2. **Bulk operations**: multi-select + batch mutate/delete/revoke across admin users, catalog hierarchy, stations.
3. **Desktop / Mobile parity** while respecting CLAUDE.md mobile + desktop mandate and existing design system.
Assumptions:
- Backend will expose streaming endpoint (`/api/admin/audit-logs/stream`) using SSE. (If absent, coordinate for addition.)
- Backend will provide/extend batch mutation endpoints or accept arrays in current ones.
- No additional design assets; follow existing Material UI / GlassCard patterns.
## Implementation Phases
### Phase 0 Prep & Validation
- Confirm backend endpoints:
- `GET /api/admin/audit-logs/stream` (SSE) payload schema.
- Batch endpoints for admins (`POST /admin/admins/bulk`, `PATCH /admin/admins/bulk-revoke`, etc.), catalog (`/admin/catalog/{entity}/bulk-delete`), stations (`/admin/stations/bulk-delete`).
- Response format + error contracts.
- Document agreements in `docs/ADMIN.md` and update API client typings before UI work.
### Phase 1 Shared Infrastructure
- Add shared admin components under `frontend/src/features/admin/components/`:
- `AdminSectionHeader`
- `AdminDataGrid` (wrapper around MUI DataGrid or Table) with checkbox selection and toolbar slot.
- `SelectionToolbar` + `BulkActionDialog`.
- `AuditLogPanel` (desktop) and `AuditLogDrawer` (mobile).
- `EmptyState`, `ErrorState`, `Skeleton` variants.
- Utility hooks/services:
- `useBulkSelection` (manages item selection, select all, reset).
- `useAuditLogStream` (SSE handling, merge into cache, pause/resume).
- `useAdminRealtimeEffect` (common real-time logic for both platforms).
- Error normalization helper for API responses.
- Update `admin.api.ts` to include bulk endpoints and streaming subscription helper.
- Ensure types in `admin.types.ts` cover new request/response payloads.
### Phase 2 Admin Users Experience
- **Desktop (`AdminUsersPage.tsx`)**:
- Replace placeholder with layout:
- Header (stats summary cards).
- `AdminDataGrid` listing admins (columns: email, role, status, created/updated, last activity).
- Toolbar actions: Invite, Revoke, Reinstate, Delete (single + bulk), export CSV placeholder.
- Inline filters/search.
- Audit log side panel fed by `useAuditLogStream`.
- Modals/forms:
- Invite admin (react-hook-form + Zod validation).
- Confirm dialogs for revoke/reinstate/delete (bulk friendly).
- State management:
- Use React Query hooks (`useAdmins`, new `useBulkRevokeAdmins`, etc.).
- Optimistic updates where safe; fallback to refetch on failure.
- Surface backend constraints (last admin protection) in toasts/dialogs.
- **Mobile (`AdminUsersMobileScreen.tsx`)**:
- Card-based list with segmented controls.
- Multi-select mode triggered by long-press or “Select” button; sticky bottom action bar for bulk operations.
- Slide-in drawer for audit log stream; allow collapse to preserve screen space.
- Ensure loading/error/empty states match mobile pattern.
### Phase 3 Vehicle Catalog Management
- Extend API hooks for per-entity bulk operations (`useDeleteMakesBulk`, etc.) and streaming updates.
- **Desktop (`AdminCatalogPage.tsx`)**:
- Two-column layout: left panel shows hierarchical tree (Makes → Models → Years → Trims → Engines). Right panel shows detail grid for selected level.
- Support multi-select in each grid with bulk delete; confirm cascading impacts (warn when deleting parents).
- Modals for create/edit per entity using shared form component (with validation & parent context).
- Audit log panel filtered to catalog-related actions.
- Show breadcrumbs + context metadata (created/updated timestamps).
- **Mobile (`AdminCatalogMobileScreen.tsx`)**:
- Drill-down navigation (list of makes → models → ...).
- Selection mode toggles for bulk delete at current depth; use bottom sheet to display actions.
- Provide “Recent Changes” sheet consuming audit stream (filtered).
- Handle cache invalidation across hierarchies (e.g., deleting a make invalidates models/years/trims queries). Consider using queryClient `invalidateQueries` with partial keys.
### Phase 4 Station Oversight
- Hook updates: add `useBulkDeleteStations`, `useBulkRestoreStations` if available, with optional `force` flag.
- **Desktop (`AdminStationsPage.tsx`)**:
- Data grid with columns (name, address, status, last modified, createdBy). Add search bar and filter chips (active, soft-deleted).
- Bulk selection with delete (soft/hard toggle), restore, export stub.
- Station detail drawer with metadata and quick actions.
- Audit log panel focusing on station events; highlight critical operations via toast (e.g., hard deletes).
- **Mobile (`AdminStationsMobileScreen.tsx`)**:
- Card list with quick actions (edit, delete, restore). Multi-select mode with sticky action bar.
- Provide filter tabs (All / Active / Deleted).
- Integrate audit log bottom sheet.
### Phase 5 Integration & Routing Enhancements
- Introduce route wrapper/components (e.g., `AdminUsersRoute`) that detect viewport using `useMediaQuery` and render desktop or mobile variant; ensures shared logic and prevents duplicate routing code.
- Update navigation flows, ensuring mobile bottom navigation can reach admin sections gracefully.
- Document keyboard shortcuts or focus management for accessibility (bulk selection, audit log toggles).
### Phase 6 Testing & QA
- Add unit tests for new hooks (`useAuditLogStream`, bulk hooks) using Jest + Testing Library. Mock EventSource for streaming tests.
- Component tests:
- Desktop grids: selection toggles, bulk action dialogs, form validation.
- Mobile screens: selection mode toggling, action bar behaviors.
- Audit log panels: streaming update rendering, pause/resume controls.
- Visual regression smoke tests if tooling available; otherwise document manual screenshot checkpoints.
- Manual QA matrix:
- Desktop ≥1280px and mobile ≤480px.
- Test flows: invite admin, revoke/reinstate, bulk revoke, catalog cascading delete, station soft/hard delete, audit log live updates.
## Deliverables Checklist
- [ ] Updated API client + types for batch + streaming.
- [ ] Shared admin UI components & utilities.
- [ ] Desktop admin pages fully functional with bulk + real-time features.
- [ ] Mobile admin screens matching functionality.
- [ ] Comprehensive tests covering new flows.
- [ ] Documentation updates (API usage, manual QA steps).
## Risks & Mitigations
- **Streaming availability**: If backend stream not ready, fall back to polling with progressive enhancement; keep SSE integration behind feature flag.
- **Bulk API inconsistencies**: Align payload format with backend; add defensive UI (disable actions until backend confirms support).
- **State synchronization**: Ensure query invalidation covers dependent entities; consider structured query keys and `queryClient.setQueryData` for incremental updates.
- **Mobile UX complexity**: Prototype selection mode early to validate ergonomics; leverage bottom sheets to avoid cramped toolbars.
## Follow-up Questions (Resolved)
1. Real-time audit logs required — implement SSE-based stream handling.
2. Bulk operations mandatory — support multi-select + batch actions across admin users, catalog entities, stations.
3. No additional design constraints — rely on existing Material UI and GlassCard paradigms.
## Handoff Notes
- Keep code comments concise per developer guidelines; avoid introducing new design systems.
- Validate hooks for Auth0 dependency (ensure disabled when unauthenticated).
- Coordinate with backend team if API gaps found; document interim shims.
- Maintain responsiveness and accessibility; ensure touch targets ≥44px and keyboard operability on desktop grids.

View File

@@ -0,0 +1,134 @@
import {
getCascadeSummary,
CatalogSelectionContext,
} from '../catalog/catalogShared';
import { buildDefaultValues } from '../catalog/catalogSchemas';
import {
CatalogEngine,
CatalogMake,
CatalogModel,
CatalogTrim,
CatalogYear,
} from '../types/admin.types';
const baseMake: CatalogMake = {
id: 'make-1',
name: 'Honda',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
const baseModel: CatalogModel = {
id: 'model-1',
makeId: baseMake.id,
name: 'Civic',
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
};
const baseYear: CatalogYear = {
id: 'year-1',
modelId: baseModel.id,
year: 2024,
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
};
const baseTrim: CatalogTrim = {
id: 'trim-1',
yearId: baseYear.id,
name: 'Sport',
createdAt: '2024-01-04T00:00:00Z',
updatedAt: '2024-01-04T00:00:00Z',
};
const baseEngine: CatalogEngine = {
id: 'engine-1',
trimId: baseTrim.id,
name: '2.0T',
displacement: '2.0L',
cylinders: 4,
fuel_type: 'Gasoline',
createdAt: '2024-01-05T00:00:00Z',
updatedAt: '2024-01-05T00:00:00Z',
};
describe('getCascadeSummary', () => {
it('describes dependent counts for makes', () => {
const modelsByMake = new Map<string, CatalogModel[]>([
[baseMake.id, [baseModel]],
]);
const yearsByModel = new Map<string, CatalogYear[]>([
[baseModel.id, [baseYear]],
]);
const trimsByYear = new Map<string, CatalogTrim[]>([
[baseYear.id, [baseTrim]],
]);
const enginesByTrim = new Map<string, CatalogEngine[]>([
[baseTrim.id, [baseEngine]],
]);
const summary = getCascadeSummary(
'makes',
[baseMake],
modelsByMake,
yearsByModel,
trimsByYear,
enginesByTrim
);
expect(summary).toContain('1 model');
expect(summary).toContain('1 year');
expect(summary).toContain('1 trim');
expect(summary).toContain('1 engine');
});
it('returns empty string when nothing selected', () => {
const summary = getCascadeSummary(
'models',
[],
new Map(),
new Map(),
new Map(),
new Map()
);
expect(summary).toBe('');
});
});
describe('buildDefaultValues', () => {
it('prefills parent context for create operations', () => {
const context: CatalogSelectionContext = {
level: 'models',
make: baseMake,
};
const defaults = buildDefaultValues('models', 'create', undefined, context);
expect(defaults.makeId).toBe(baseMake.id);
expect(defaults.name).toBe('');
});
it('hydrates existing entity data for editing engines', () => {
const context: CatalogSelectionContext = {
level: 'engines',
make: baseMake,
model: baseModel,
year: baseYear,
trim: baseTrim,
};
const defaults = buildDefaultValues(
'engines',
'edit',
baseEngine,
context
);
expect(defaults.name).toBe(baseEngine.name);
expect(defaults.trimId).toBe(baseTrim.id);
expect(defaults.displacement).toBe('2.0L');
expect(defaults.cylinders).toBe(4);
expect(defaults.fuel_type).toBe('Gasoline');
});
});

View File

@@ -0,0 +1,43 @@
/**
* @ai-summary Snapshot tests for AdminSectionHeader component
*/
import React from 'react';
import { render } from '@testing-library/react';
import { AdminSectionHeader } from '../../components/AdminSectionHeader';
describe('AdminSectionHeader', () => {
it('should render with title and stats', () => {
const { container } = render(
<AdminSectionHeader
title="Vehicle Catalog"
stats={[
{ label: 'Makes', value: 100 },
{ label: 'Models', value: 500 },
{ label: 'Years', value: 20 },
]}
/>
);
expect(container).toMatchSnapshot();
});
it('should render with empty stats', () => {
const { container } = render(
<AdminSectionHeader title="Admin Users" stats={[]} />
);
expect(container).toMatchSnapshot();
});
it('should format large numbers with locale', () => {
const { container } = render(
<AdminSectionHeader
title="Station Management"
stats={[{ label: 'Total Stations', value: 10000 }]}
/>
);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,39 @@
/**
* @ai-summary Tests for AdminSkeleton components
*/
import React from 'react';
import { render } from '@testing-library/react';
import { AdminSkeleton } from '../../components/AdminSkeleton';
describe('AdminSkeleton', () => {
describe('SkeletonRow', () => {
it('should render default number of rows', () => {
const { container } = render(<AdminSkeleton.SkeletonRow />);
expect(container).toMatchSnapshot();
});
it('should render specified number of rows', () => {
const { container } = render(<AdminSkeleton.SkeletonRow count={5} />);
expect(container).toMatchSnapshot();
expect(container.querySelectorAll('.MuiSkeleton-root')).toHaveLength(15); // 3 skeletons per row * 5 rows
});
});
describe('SkeletonCard', () => {
it('should render default number of cards', () => {
const { container } = render(<AdminSkeleton.SkeletonCard />);
expect(container).toMatchSnapshot();
});
it('should render specified number of cards', () => {
const { container } = render(<AdminSkeleton.SkeletonCard count={4} />);
expect(container).toMatchSnapshot();
expect(container.querySelectorAll('.MuiCard-root')).toHaveLength(4);
});
});
});

View File

@@ -0,0 +1,83 @@
/**
* @ai-summary Tests for BulkActionDialog component
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { BulkActionDialog } from '../../components/BulkActionDialog';
describe('BulkActionDialog', () => {
const defaultProps = {
open: true,
title: 'Delete Items?',
message: 'This action cannot be undone.',
items: ['Item 1', 'Item 2', 'Item 3'],
onConfirm: jest.fn(),
onCancel: jest.fn(),
};
it('should render dialog when open', () => {
const { container } = render(<BulkActionDialog {...defaultProps} />);
expect(container).toMatchSnapshot();
expect(screen.getByText('Delete Items?')).toBeInTheDocument();
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
});
it('should display list of items', () => {
render(<BulkActionDialog {...defaultProps} />);
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
it('should call onConfirm when confirm button clicked', () => {
const handleConfirm = jest.fn();
render(<BulkActionDialog {...defaultProps} onConfirm={handleConfirm} />);
fireEvent.click(screen.getByText('Confirm'));
expect(handleConfirm).toHaveBeenCalledTimes(1);
});
it('should call onCancel when cancel button clicked', () => {
const handleCancel = jest.fn();
render(<BulkActionDialog {...defaultProps} onCancel={handleCancel} />);
fireEvent.click(screen.getByText('Cancel'));
expect(handleCancel).toHaveBeenCalledTimes(1);
});
it('should disable buttons when loading', () => {
render(<BulkActionDialog {...defaultProps} loading={true} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
const cancelButton = screen.getByRole('button', { name: /cancel/i });
expect(confirmButton).toBeDisabled();
expect(cancelButton).toBeDisabled();
});
it('should show loading spinner when loading', () => {
const { container } = render(
<BulkActionDialog {...defaultProps} loading={true} />
);
expect(container.querySelector('.MuiCircularProgress-root')).toBeInTheDocument();
});
it('should support custom button text', () => {
render(
<BulkActionDialog
{...defaultProps}
confirmText="Delete Now"
cancelText="Go Back"
/>
);
expect(screen.getByText('Delete Now')).toBeInTheDocument();
expect(screen.getByText('Go Back')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
/**
* @ai-summary Tests for EmptyState component
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { EmptyState } from '../../components/EmptyState';
describe('EmptyState', () => {
it('should render with title and description', () => {
const { container } = render(
<EmptyState
title="No Data"
description="Start by adding your first item"
/>
);
expect(container).toMatchSnapshot();
expect(screen.getByText('No Data')).toBeInTheDocument();
expect(screen.getByText('Start by adding your first item')).toBeInTheDocument();
});
it('should render with icon', () => {
const { container } = render(
<EmptyState
icon={<div data-testid="test-icon">Icon</div>}
title="Empty"
description="No items found"
/>
);
expect(container).toMatchSnapshot();
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
});
it('should render action button when provided', () => {
const handleAction = jest.fn();
render(
<EmptyState
title="No Items"
description="Add your first item"
action={{ label: 'Add Item', onClick: handleAction }}
/>
);
const button = screen.getByText('Add Item');
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(handleAction).toHaveBeenCalledTimes(1);
});
it('should not render action button when not provided', () => {
render(<EmptyState title="Empty" description="No data" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,46 @@
/**
* @ai-summary Tests for ErrorState component
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorState } from '../../components/ErrorState';
describe('ErrorState', () => {
it('should render error message', () => {
const error = new Error('Failed to load data');
const { container } = render(<ErrorState error={error} />);
expect(container).toMatchSnapshot();
expect(screen.getByText('Failed to load data')).toBeInTheDocument();
});
it('should render retry button when onRetry provided', () => {
const handleRetry = jest.fn();
const error = new Error('Network error');
render(<ErrorState error={error} onRetry={handleRetry} />);
const retryButton = screen.getByText('Retry');
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton);
expect(handleRetry).toHaveBeenCalledTimes(1);
});
it('should not render retry button when onRetry not provided', () => {
const error = new Error('Error occurred');
render(<ErrorState error={error} />);
expect(screen.queryByText('Retry')).not.toBeInTheDocument();
});
it('should show default message when error has no message', () => {
const error = new Error();
render(<ErrorState error={error} />);
expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
/**
* @ai-summary Tests for SelectionToolbar component
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { SelectionToolbar } from '../../components/SelectionToolbar';
describe('SelectionToolbar', () => {
it('should not render when selectedCount is 0', () => {
const { container } = render(
<SelectionToolbar selectedCount={0} onClear={jest.fn()} />
);
expect(container.firstChild).toBeNull();
});
it('should render when items are selected', () => {
const { container } = render(
<SelectionToolbar selectedCount={3} onClear={jest.fn()} />
);
expect(container).toMatchSnapshot();
expect(screen.getByText('Selected: 3')).toBeInTheDocument();
});
it('should call onClear when Clear button clicked', () => {
const handleClear = jest.fn();
render(<SelectionToolbar selectedCount={2} onClear={handleClear} />);
fireEvent.click(screen.getByText('Clear'));
expect(handleClear).toHaveBeenCalledTimes(1);
});
it('should call onSelectAll when Select All button clicked', () => {
const handleSelectAll = jest.fn();
render(
<SelectionToolbar
selectedCount={2}
onSelectAll={handleSelectAll}
onClear={jest.fn()}
/>
);
fireEvent.click(screen.getByText('Select All'));
expect(handleSelectAll).toHaveBeenCalledTimes(1);
});
it('should render custom action buttons', () => {
const { container } = render(
<SelectionToolbar selectedCount={3} onClear={jest.fn()}>
<button>Delete</button>
<button>Export</button>
</SelectionToolbar>
);
expect(container).toMatchSnapshot();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,119 @@
/**
* @ai-summary Tests for useBulkSelection hook
*/
import { renderHook, act } from '@testing-library/react';
import { useBulkSelection } from '../../hooks/useBulkSelection';
describe('useBulkSelection', () => {
const mockItems = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
{ id: '3', name: 'Item 3' },
];
it('should initialize with empty selection', () => {
const { result } = renderHook(() =>
useBulkSelection({ items: mockItems })
);
expect(result.current.count).toBe(0);
expect(result.current.selected.size).toBe(0);
});
it('should toggle individual item selection', () => {
const { result } = renderHook(() =>
useBulkSelection({ items: mockItems })
);
act(() => {
result.current.toggleItem('1');
});
expect(result.current.count).toBe(1);
expect(result.current.isSelected('1')).toBe(true);
act(() => {
result.current.toggleItem('1');
});
expect(result.current.count).toBe(0);
expect(result.current.isSelected('1')).toBe(false);
});
it('should toggle all items', () => {
const { result } = renderHook(() =>
useBulkSelection({ items: mockItems })
);
act(() => {
result.current.toggleAll(mockItems);
});
expect(result.current.count).toBe(3);
expect(result.current.isSelected('1')).toBe(true);
expect(result.current.isSelected('2')).toBe(true);
expect(result.current.isSelected('3')).toBe(true);
act(() => {
result.current.toggleAll(mockItems);
});
expect(result.current.count).toBe(0);
});
it('should reset all selections', () => {
const { result } = renderHook(() =>
useBulkSelection({ items: mockItems })
);
act(() => {
result.current.toggleItem('1');
result.current.toggleItem('2');
});
expect(result.current.count).toBe(2);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});
it('should return selected items', () => {
const { result } = renderHook(() =>
useBulkSelection({ items: mockItems })
);
act(() => {
result.current.toggleItem('1');
result.current.toggleItem('3');
});
expect(result.current.selectedItems).toHaveLength(2);
expect(result.current.selectedItems[0].id).toBe('1');
expect(result.current.selectedItems[1].id).toBe('3');
});
it('should support custom key extractor', () => {
const customItems = [
{ customId: 'a1', name: 'Item A' },
{ customId: 'a2', name: 'Item B' },
];
const { result } = renderHook(() =>
useBulkSelection({
items: customItems,
keyExtractor: (item) => item.customId,
})
);
act(() => {
result.current.toggleItem('a1');
});
expect(result.current.count).toBe(1);
expect(result.current.isSelected('a1')).toBe(true);
});
});

View File

@@ -63,8 +63,8 @@ export const adminApi = {
// Catalog - Makes // Catalog - Makes
listMakes: async (): Promise<CatalogMake[]> => { listMakes: async (): Promise<CatalogMake[]> => {
const response = await apiClient.get<CatalogMake[]>('/admin/catalog/makes'); const response = await apiClient.get<{ makes: CatalogMake[] }>('/admin/catalog/makes');
return response.data; return response.data.makes;
}, },
createMake: async (data: CreateCatalogMakeRequest): Promise<CatalogMake> => { createMake: async (data: CreateCatalogMakeRequest): Promise<CatalogMake> => {
@@ -82,10 +82,11 @@ export const adminApi = {
}, },
// Catalog - Models // Catalog - Models
listModels: async (makeId?: string): Promise<CatalogModel[]> => { listModels: async (makeId: string): Promise<CatalogModel[]> => {
const url = makeId ? `/admin/catalog/models?make_id=${makeId}` : '/admin/catalog/models'; const response = await apiClient.get<{ models: CatalogModel[] }>(
const response = await apiClient.get<CatalogModel[]>(url); `/admin/catalog/makes/${makeId}/models`
return response.data; );
return response.data.models;
}, },
createModel: async (data: CreateCatalogModelRequest): Promise<CatalogModel> => { createModel: async (data: CreateCatalogModelRequest): Promise<CatalogModel> => {
@@ -103,10 +104,11 @@ export const adminApi = {
}, },
// Catalog - Years // Catalog - Years
listYears: async (modelId?: string): Promise<CatalogYear[]> => { listYears: async (modelId: string): Promise<CatalogYear[]> => {
const url = modelId ? `/admin/catalog/years?model_id=${modelId}` : '/admin/catalog/years'; const response = await apiClient.get<{ years: CatalogYear[] }>(
const response = await apiClient.get<CatalogYear[]>(url); `/admin/catalog/models/${modelId}/years`
return response.data; );
return response.data.years;
}, },
createYear: async (data: CreateCatalogYearRequest): Promise<CatalogYear> => { createYear: async (data: CreateCatalogYearRequest): Promise<CatalogYear> => {
@@ -119,10 +121,11 @@ export const adminApi = {
}, },
// Catalog - Trims // Catalog - Trims
listTrims: async (yearId?: string): Promise<CatalogTrim[]> => { listTrims: async (yearId: string): Promise<CatalogTrim[]> => {
const url = yearId ? `/admin/catalog/trims?year_id=${yearId}` : '/admin/catalog/trims'; const response = await apiClient.get<{ trims: CatalogTrim[] }>(
const response = await apiClient.get<CatalogTrim[]>(url); `/admin/catalog/years/${yearId}/trims`
return response.data; );
return response.data.trims;
}, },
createTrim: async (data: CreateCatalogTrimRequest): Promise<CatalogTrim> => { createTrim: async (data: CreateCatalogTrimRequest): Promise<CatalogTrim> => {
@@ -140,10 +143,11 @@ export const adminApi = {
}, },
// Catalog - Engines // Catalog - Engines
listEngines: async (trimId?: string): Promise<CatalogEngine[]> => { listEngines: async (trimId: string): Promise<CatalogEngine[]> => {
const url = trimId ? `/admin/catalog/engines?trim_id=${trimId}` : '/admin/catalog/engines'; const response = await apiClient.get<{ engines: CatalogEngine[] }>(
const response = await apiClient.get<CatalogEngine[]>(url); `/admin/catalog/trims/${trimId}/engines`
return response.data; );
return response.data.engines;
}, },
createEngine: async (data: CreateCatalogEngineRequest): Promise<CatalogEngine> => { createEngine: async (data: CreateCatalogEngineRequest): Promise<CatalogEngine> => {

View File

@@ -0,0 +1,151 @@
import { z } from 'zod';
import {
CatalogLevel,
CatalogRow,
CatalogSelectionContext,
} from './catalogShared';
import {
CatalogMake,
CatalogModel,
CatalogYear,
CatalogTrim,
CatalogEngine,
} from '../types/admin.types';
export type CatalogFormValues = {
name?: string;
makeId?: string;
modelId?: string;
year?: number;
yearId?: string;
trimId?: string;
displacement?: string;
cylinders?: number;
fuel_type?: string;
};
export const makeSchema = z.object({
name: z.string().min(1, 'Name is required'),
});
export const modelSchema = z.object({
name: z.string().min(1, 'Name is required'),
makeId: z.string().min(1, 'Select a make'),
});
export const yearSchema = z.object({
modelId: z.string().min(1, 'Select a model'),
year: z
.coerce.number()
.int()
.min(1900, 'Enter a valid year')
.max(2100, 'Enter a valid year'),
});
export const trimSchema = z.object({
name: z.string().min(1, 'Name is required'),
yearId: z.string().min(1, 'Select a year'),
});
export const engineSchema = z.object({
name: z.string().min(1, 'Name is required'),
trimId: z.string().min(1, 'Select a trim'),
displacement: z.string().optional(),
cylinders: z
.preprocess(
(value) =>
value === '' || value === null || value === undefined
? undefined
: Number(value),
z
.number()
.int()
.positive('Cylinders must be positive')
.optional()
),
fuel_type: z.string().optional(),
});
export const getSchemaForLevel = (level: CatalogLevel) => {
switch (level) {
case 'makes':
return makeSchema;
case 'models':
return modelSchema;
case 'years':
return yearSchema;
case 'trims':
return trimSchema;
case 'engines':
return engineSchema;
default:
return makeSchema;
}
};
export const buildDefaultValues = (
level: CatalogLevel,
mode: 'create' | 'edit',
entity: CatalogRow | undefined,
context: CatalogSelectionContext
): CatalogFormValues => {
if (mode === 'edit' && entity) {
switch (level) {
case 'makes':
return { name: (entity as CatalogMake).name };
case 'models':
return {
name: (entity as CatalogModel).name,
makeId: (entity as CatalogModel).makeId,
};
case 'years':
return {
modelId: (entity as CatalogYear).modelId,
year: (entity as CatalogYear).year,
};
case 'trims':
return {
name: (entity as CatalogTrim).name,
yearId: (entity as CatalogTrim).yearId,
};
case 'engines':
return {
name: (entity as CatalogEngine).name,
trimId: (entity as CatalogEngine).trimId,
displacement: (entity as CatalogEngine).displacement ?? undefined,
cylinders: (entity as CatalogEngine).cylinders ?? undefined,
fuel_type: (entity as CatalogEngine).fuel_type ?? undefined,
};
default:
return {};
}
}
switch (level) {
case 'models':
return {
name: '',
makeId: context.make?.id ?? '',
};
case 'years':
return {
modelId: context.model?.id ?? '',
year: undefined,
};
case 'trims':
return {
name: '',
yearId: context.year?.id ?? '',
};
case 'engines':
return {
name: '',
trimId: context.trim?.id ?? '',
displacement: '',
fuel_type: '',
};
case 'makes':
default:
return { name: '' };
}
};

View File

@@ -0,0 +1,157 @@
import {
CatalogEngine,
CatalogMake,
CatalogModel,
CatalogTrim,
CatalogYear,
} from '../types/admin.types';
export type CatalogLevel = 'makes' | 'models' | 'years' | 'trims' | 'engines';
export type CatalogRow =
| CatalogMake
| CatalogModel
| CatalogYear
| CatalogTrim
| CatalogEngine;
export interface CatalogSelectionContext {
level: CatalogLevel;
make?: CatalogMake;
model?: CatalogModel;
year?: CatalogYear;
trim?: CatalogTrim;
}
export const LEVEL_LABEL: Record<CatalogLevel, string> = {
makes: 'Makes',
models: 'Models',
years: 'Years',
trims: 'Trims',
engines: 'Engines',
};
export const LEVEL_SINGULAR_LABEL: Record<CatalogLevel, string> = {
makes: 'Make',
models: 'Model',
years: 'Year',
trims: 'Trim',
engines: 'Engine',
};
export const NEXT_LEVEL: Record<CatalogLevel, CatalogLevel | null> = {
makes: 'models',
models: 'years',
years: 'trims',
trims: 'engines',
engines: null,
};
export const pluralize = (count: number, singular: string): string =>
`${count} ${singular}${count === 1 ? '' : 's'}`;
export const getCascadeSummary = (
level: CatalogLevel,
selectedItems: CatalogRow[],
modelsByMake: Map<string, CatalogModel[]>,
yearsByModel: Map<string, CatalogYear[]>,
trimsByYear: Map<string, CatalogTrim[]>,
enginesByTrim: Map<string, CatalogEngine[]>
): string => {
if (selectedItems.length === 0) {
return '';
}
if (level === 'engines') {
return 'Deleting engines will remove their configuration details.';
}
let modelCount = 0;
let yearCount = 0;
let trimCount = 0;
let engineCount = 0;
if (level === 'makes') {
selectedItems.forEach((item) => {
const make = item as CatalogMake;
const makeModels = modelsByMake.get(make.id) ?? [];
modelCount += makeModels.length;
makeModels.forEach((model) => {
const modelYears = yearsByModel.get(model.id) ?? [];
yearCount += modelYears.length;
modelYears.forEach((year) => {
const yearTrims = trimsByYear.get(year.id) ?? [];
trimCount += yearTrims.length;
yearTrims.forEach((trim) => {
const trimEngines = enginesByTrim.get(trim.id) ?? [];
engineCount += trimEngines.length;
});
});
});
});
return `Deleting ${selectedItems.length} ${LEVEL_LABEL.makes.toLowerCase()} will also remove ${pluralize(
modelCount,
'model'
)}, ${pluralize(yearCount, 'year')}, ${pluralize(
trimCount,
'trim'
)}, and ${pluralize(engineCount, 'engine')}.`;
}
if (level === 'models') {
selectedItems.forEach((item) => {
const model = item as CatalogModel;
const modelYears = yearsByModel.get(model.id) ?? [];
yearCount += modelYears.length;
modelYears.forEach((year) => {
const yearTrims = trimsByYear.get(year.id) ?? [];
trimCount += yearTrims.length;
yearTrims.forEach((trim) => {
const trimEngines = enginesByTrim.get(trim.id) ?? [];
engineCount += trimEngines.length;
});
});
});
return `Deleting ${selectedItems.length} ${LEVEL_LABEL.models.toLowerCase()} will also remove ${pluralize(
yearCount,
'year'
)}, ${pluralize(trimCount, 'trim')}, and ${pluralize(
engineCount,
'engine'
)}.`;
}
if (level === 'years') {
selectedItems.forEach((item) => {
const year = item as CatalogYear;
const yearTrims = trimsByYear.get(year.id) ?? [];
trimCount += yearTrims.length;
yearTrims.forEach((trim) => {
const trimEngines = enginesByTrim.get(trim.id) ?? [];
engineCount += trimEngines.length;
});
});
return `Deleting ${selectedItems.length} ${LEVEL_LABEL.years.toLowerCase()} will also remove ${pluralize(
trimCount,
'trim'
)} and ${pluralize(engineCount, 'engine')}.`;
}
if (level === 'trims') {
selectedItems.forEach((item) => {
const trim = item as CatalogTrim;
const trimEngines = enginesByTrim.get(trim.id) ?? [];
engineCount += trimEngines.length;
});
return `Deleting ${selectedItems.length} ${LEVEL_LABEL.trims.toLowerCase()} will also remove ${pluralize(
engineCount,
'engine'
)}.`;
}
return '';
};

View File

@@ -0,0 +1,253 @@
/**
* @ai-summary Reusable data grid component with selection and pagination
* @ai-context Table display with checkbox selection, sorting, and error/loading states
*/
import React from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Checkbox,
Paper,
IconButton,
Typography,
TableSortLabel,
} from '@mui/material';
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
import { EmptyState } from './EmptyState';
import { ErrorState } from './ErrorState';
import { AdminSkeleton } from './AdminSkeleton';
/**
* Column definition for data grid
*/
export interface GridColumn<T = any> {
field: keyof T | string;
headerName: string;
sortable?: boolean;
width?: string | number;
renderCell?: (row: T) => React.ReactNode;
}
/**
* Props for AdminDataGrid component
*/
export interface AdminDataGridProps<T = any> {
rows: T[];
columns: GridColumn<T>[];
selectedIds: Set<string>;
onSelectChange: (id: string) => void;
onSelectAll?: () => void;
loading?: boolean;
error?: Error | null;
onRetry?: () => void;
toolbar?: React.ReactNode;
getRowId?: (row: T) => string;
emptyMessage?: string;
page?: number;
onPageChange?: (page: number) => void;
totalPages?: number;
}
/**
* Data grid component with selection, sorting, and pagination
*
* @example
* ```tsx
* <AdminDataGrid
* rows={data}
* columns={[
* { field: 'name', headerName: 'Name', sortable: true },
* { field: 'createdAt', headerName: 'Created', renderCell: (row) => formatDate(row.createdAt) }
* ]}
* selectedIds={selected}
* onSelectChange={toggleItem}
* onSelectAll={toggleAll}
* />
* ```
*/
export function AdminDataGrid<T extends Record<string, any>>({
rows,
columns,
selectedIds,
onSelectChange,
onSelectAll,
loading = false,
error = null,
onRetry,
toolbar,
getRowId = (row) => row.id,
emptyMessage = 'No data available',
page = 0,
onPageChange,
totalPages = 1,
}: AdminDataGridProps<T>): React.ReactElement {
// Loading state
if (loading) {
return (
<Box>
{toolbar}
<AdminSkeleton.SkeletonRow count={5} />
</Box>
);
}
// Error state
if (error) {
return (
<Box>
{toolbar}
<ErrorState error={error} onRetry={onRetry} />
</Box>
);
}
// Empty state
if (rows.length === 0) {
return (
<Box>
{toolbar}
<EmptyState
title="No Data"
description={emptyMessage}
icon={null}
/>
</Box>
);
}
const allSelected =
rows.length > 0 && rows.every((row) => selectedIds.has(getRowId(row)));
const someSelected =
rows.some((row) => selectedIds.has(getRowId(row))) && !allSelected;
return (
<Box>
{toolbar}
<TableContainer component={Paper} sx={{ boxShadow: 1 }}>
<Table>
<TableHead>
<TableRow>
{/* Checkbox column */}
<TableCell padding="checkbox">
{onSelectAll && (
<Checkbox
indeterminate={someSelected}
checked={allSelected}
onChange={onSelectAll}
inputProps={{
'aria-label': 'select all',
}}
/>
)}
</TableCell>
{/* Data columns */}
{columns.map((column) => (
<TableCell
key={String(column.field)}
sx={{
width: column.width,
fontWeight: 600,
}}
>
{column.sortable ? (
<TableSortLabel>
{column.headerName}
</TableSortLabel>
) : (
column.headerName
)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => {
const rowId = getRowId(row);
const isSelected = selectedIds.has(rowId);
return (
<TableRow
key={rowId}
selected={isSelected}
hover
sx={{
cursor: 'pointer',
'&.Mui-selected': {
backgroundColor: 'action.selected',
},
}}
>
{/* Checkbox cell */}
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
onChange={() => onSelectChange(rowId)}
inputProps={{
'aria-label': `select row ${rowId}`,
}}
sx={{
minWidth: 44,
minHeight: 44,
}}
/>
</TableCell>
{/* Data cells */}
{columns.map((column) => (
<TableCell key={String(column.field)}>
{column.renderCell
? column.renderCell(row)
: row[column.field]}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{/* Pagination controls */}
{onPageChange && totalPages > 1 && (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
mt: 2,
gap: 1,
}}
>
<Typography variant="body2" color="text.secondary">
Page {page + 1} of {totalPages}
</Typography>
<IconButton
onClick={() => onPageChange(page - 1)}
disabled={page === 0}
aria-label="previous page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronLeft />
</IconButton>
<IconButton
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages - 1}
aria-label="next page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronRight />
</IconButton>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,92 @@
/**
* @ai-summary Header component for admin sections with title and stats
* @ai-context Displays section title with stat cards showing counts
*/
import React from 'react';
import { Box, Typography, Card, CardContent, Grid } from '@mui/material';
/**
* Stat item definition
*/
export interface StatItem {
label: string;
value: number;
}
/**
* Props for AdminSectionHeader component
*/
export interface AdminSectionHeaderProps {
title: string;
stats: StatItem[];
}
/**
* Header component displaying title and stats cards
*
* @example
* ```tsx
* <AdminSectionHeader
* title="Vehicle Catalog"
* stats={[
* { label: 'Makes', value: 100 },
* { label: 'Models', value: 500 }
* ]}
* />
* ```
*/
export const AdminSectionHeader: React.FC<AdminSectionHeaderProps> = ({
title,
stats,
}) => {
return (
<Box sx={{ mb: 4 }}>
<Typography
variant="h4"
component="h1"
sx={{
mb: 3,
fontWeight: 600,
color: 'text.primary',
}}
>
{title}
</Typography>
<Grid container spacing={2}>
{stats.map((stat) => (
<Grid item xs={12} sm={6} md={3} key={stat.label}>
<Card
sx={{
height: '100%',
boxShadow: 1,
transition: 'box-shadow 0.2s',
'&:hover': {
boxShadow: 2,
},
}}
>
<CardContent>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1 }}
>
{stat.label}
</Typography>
<Typography
variant="h5"
component="div"
sx={{ fontWeight: 600 }}
>
{stat.value.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
};

View File

@@ -0,0 +1,94 @@
/**
* @ai-summary Skeleton loading components for admin views
* @ai-context Provides skeleton rows for tables and cards for mobile views
*/
import React from 'react';
import { Box, Skeleton, Card, CardContent } from '@mui/material';
/**
* Props for SkeletonRow component
*/
interface SkeletonRowProps {
count?: number;
}
/**
* Skeleton loading rows for table views
*
* @example
* ```tsx
* <AdminSkeleton.SkeletonRow count={5} />
* ```
*/
const SkeletonRow: React.FC<SkeletonRowProps> = ({ count = 3 }) => {
return (
<Box sx={{ p: 2 }}>
{Array.from({ length: count }).map((_, index) => (
<Box
key={index}
sx={{
display: 'flex',
gap: 2,
mb: 2,
alignItems: 'center',
}}
>
<Skeleton variant="rectangular" width={40} height={40} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={24} />
<Skeleton variant="text" width="40%" height={20} />
</Box>
<Skeleton variant="rectangular" width={80} height={32} />
</Box>
))}
</Box>
);
};
/**
* Props for SkeletonCard component
*/
interface SkeletonCardProps {
count?: number;
}
/**
* Skeleton loading cards for mobile views
*
* @example
* ```tsx
* <AdminSkeleton.SkeletonCard count={3} />
* ```
*/
const SkeletonCard: React.FC<SkeletonCardProps> = ({ count = 3 }) => {
return (
<Box>
{Array.from({ length: count }).map((_, index) => (
<Card key={index} sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Skeleton variant="rectangular" width={40} height={40} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={20} />
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Skeleton variant="rectangular" width={80} height={36} />
<Skeleton variant="rectangular" width={80} height={36} />
</Box>
</CardContent>
</Card>
))}
</Box>
);
};
/**
* Admin skeleton loading components
*/
export const AdminSkeleton = {
SkeletonRow,
SkeletonCard,
};

View File

@@ -0,0 +1,224 @@
/**
* @ai-summary Mobile bottom sheet drawer for audit logs
* @ai-context Bottom drawer showing paginated audit logs optimized for mobile
*/
import React from 'react';
import {
Drawer,
Box,
Typography,
IconButton,
List,
ListItem,
ListItemText,
Divider,
} from '@mui/material';
import { Close, ChevronLeft, ChevronRight } from '@mui/icons-material';
import { formatDistanceToNow } from 'date-fns';
import { useAuditLogStream } from '../hooks/useAuditLogStream';
import { AdminSkeleton } from './AdminSkeleton';
import { EmptyState } from './EmptyState';
import { ErrorState } from './ErrorState';
/**
* Props for AuditLogDrawer component
*/
export interface AuditLogDrawerProps {
open: boolean;
onClose: () => void;
resourceType?: 'admin' | 'catalog' | 'station';
limit?: number;
pollIntervalMs?: number;
}
/**
* Mobile bottom sheet drawer for displaying audit logs
*
* @example
* ```tsx
* <AuditLogDrawer
* open={drawerOpen}
* onClose={() => setDrawerOpen(false)}
* resourceType="station"
* limit={50}
* />
* ```
*/
export const AuditLogDrawer: React.FC<AuditLogDrawerProps> = ({
open,
onClose,
resourceType,
limit = 50,
pollIntervalMs = 5000,
}) => {
const {
logs,
loading,
error,
pagination,
hasMore,
nextPage,
prevPage,
refetch,
lastUpdated,
} = useAuditLogStream({
resourceType,
limit,
pollIntervalMs,
});
const resourceLabel = resourceType
? `${resourceType.charAt(0).toUpperCase()}${resourceType.slice(1)} Changes`
: 'All Changes';
return (
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
PaperProps={{
sx: {
maxHeight: '80vh',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
}}
>
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box
sx={{
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Audit Logs
</Typography>
<Typography variant="caption" color="text.secondary">
{resourceLabel}
</Typography>
</Box>
<IconButton
onClick={onClose}
aria-label="close"
sx={{ minWidth: 44, minHeight: 44 }}
>
<Close />
</IconButton>
</Box>
{/* Content */}
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 200 }}>
{loading && <AdminSkeleton.SkeletonCard count={3} />}
{error && <ErrorState error={error} onRetry={refetch} />}
{!loading && !error && logs.length === 0 && (
<EmptyState
title="No Audit Logs"
description="No activity recorded yet"
icon={null}
/>
)}
{!loading && !error && logs.length > 0 && (
<List>
{logs.map((log) => (
<React.Fragment key={log.id}>
<ListItem
alignItems="flex-start"
sx={{
minHeight: 64,
}}
>
<ListItemText
primary={
<Typography
variant="body1"
sx={{ fontWeight: 600, mb: 0.5 }}
>
{log.action}
</Typography>
}
secondary={
<Box>
<Typography
variant="body2"
component="span"
color="text.secondary"
>
{formatDistanceToNow(new Date(log.createdAt), {
addSuffix: true,
})}
</Typography>
{log.resourceType && (
<Typography
variant="body2"
component="span"
color="text.secondary"
sx={{ ml: 1 }}
>
| {log.resourceType}
</Typography>
)}
</Box>
}
/>
</ListItem>
<Divider component="li" />
</React.Fragment>
))}
</List>
)}
</Box>
{/* Footer with pagination */}
{!loading && logs.length > 0 && (
<Box
sx={{
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: 1,
borderColor: 'divider',
minHeight: 64,
}}
>
<Typography variant="body2" color="text.secondary">
Updated{' '}
{formatDistanceToNow(lastUpdated, {
addSuffix: true,
})}
</Typography>
<Box>
<IconButton
onClick={prevPage}
disabled={pagination.offset === 0}
aria-label="previous page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronLeft />
</IconButton>
<IconButton
onClick={nextPage}
disabled={!hasMore}
aria-label="next page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronRight />
</IconButton>
</Box>
</Box>
)}
</Box>
</Drawer>
);
};

View File

@@ -0,0 +1,219 @@
/**
* @ai-summary Desktop sidebar panel for audit logs
* @ai-context Collapsible panel showing paginated audit logs for desktop views
*/
import React, { useState } from 'react';
import {
Box,
Paper,
Typography,
IconButton,
List,
ListItem,
ListItemText,
Collapse,
Divider,
} from '@mui/material';
import {
ExpandLess,
ExpandMore,
ChevronLeft,
ChevronRight,
} from '@mui/icons-material';
import { formatDistanceToNow } from 'date-fns';
import { useAuditLogStream } from '../hooks/useAuditLogStream';
import { AdminSkeleton } from './AdminSkeleton';
import { EmptyState } from './EmptyState';
import { ErrorState } from './ErrorState';
/**
* Props for AuditLogPanel component
*/
export interface AuditLogPanelProps {
resourceType?: 'admin' | 'catalog' | 'station';
limit?: number;
pollIntervalMs?: number;
}
/**
* Desktop sidebar panel for displaying audit logs
*
* @example
* ```tsx
* <AuditLogPanel
* resourceType="catalog"
* limit={50}
* pollIntervalMs={5000}
* />
* ```
*/
export const AuditLogPanel: React.FC<AuditLogPanelProps> = ({
resourceType,
limit = 50,
pollIntervalMs = 5000,
}) => {
const [collapsed, setCollapsed] = useState(false);
const {
logs,
loading,
error,
pagination,
hasMore,
nextPage,
prevPage,
refetch,
lastUpdated,
} = useAuditLogStream({
resourceType,
limit,
pollIntervalMs,
});
const resourceLabel = resourceType
? `${resourceType.charAt(0).toUpperCase()}${resourceType.slice(1)} Changes`
: 'All Changes';
return (
<Paper
sx={{
width: 320,
height: 'fit-content',
maxHeight: 'calc(100vh - 200px)',
display: 'flex',
flexDirection: 'column',
boxShadow: 2,
}}
>
{/* Header */}
<Box
sx={{
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Audit Logs
</Typography>
<Typography variant="caption" color="text.secondary">
{resourceLabel} (Last {limit})
</Typography>
</Box>
<IconButton
onClick={() => setCollapsed(!collapsed)}
size="small"
aria-label={collapsed ? 'expand' : 'collapse'}
sx={{ minWidth: 44, minHeight: 44 }}
>
{collapsed ? <ExpandMore /> : <ExpandLess />}
</IconButton>
</Box>
{/* Content */}
<Collapse in={!collapsed}>
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 200 }}>
{loading && <AdminSkeleton.SkeletonRow count={3} />}
{error && <ErrorState error={error} onRetry={refetch} />}
{!loading && !error && logs.length === 0 && (
<EmptyState
title="No Audit Logs"
description="No activity recorded yet"
icon={null}
/>
)}
{!loading && !error && logs.length > 0 && (
<List dense>
{logs.map((log) => (
<React.Fragment key={log.id}>
<ListItem alignItems="flex-start">
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{log.action}
</Typography>
}
secondary={
<Box>
<Typography
variant="caption"
component="span"
color="text.secondary"
>
{formatDistanceToNow(new Date(log.createdAt), {
addSuffix: true,
})}
</Typography>
{log.resourceType && (
<Typography
variant="caption"
component="span"
color="text.secondary"
sx={{ ml: 1 }}
>
| {log.resourceType}
</Typography>
)}
</Box>
}
/>
</ListItem>
<Divider component="li" />
</React.Fragment>
))}
</List>
)}
</Box>
{/* Footer with pagination */}
{!loading && logs.length > 0 && (
<Box
sx={{
p: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: 1,
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Updated{' '}
{formatDistanceToNow(lastUpdated, {
addSuffix: true,
})}
</Typography>
<Box>
<IconButton
size="small"
onClick={prevPage}
disabled={pagination.offset === 0}
aria-label="previous page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronLeft />
</IconButton>
<IconButton
size="small"
onClick={nextPage}
disabled={!hasMore}
aria-label="next page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronRight />
</IconButton>
</Box>
</Box>
)}
</Collapse>
</Paper>
);
};

View File

@@ -0,0 +1,137 @@
/**
* @ai-summary Confirmation dialog for bulk actions
* @ai-context Modal for confirming destructive bulk operations with item list
*/
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
List,
ListItem,
ListItemText,
CircularProgress,
Box,
} from '@mui/material';
/**
* Props for BulkActionDialog component
*/
export interface BulkActionDialogProps {
open: boolean;
title: string;
message: string;
items: string[];
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
confirmText?: string;
cancelText?: string;
}
/**
* Confirmation dialog for bulk actions
*
* @example
* ```tsx
* <BulkActionDialog
* open={open}
* title="Delete 3 makes?"
* message="This will delete 15 dependent models. Continue?"
* items={['Honda', 'Toyota', 'Ford']}
* onConfirm={handleConfirm}
* onCancel={handleCancel}
* loading={deleting}
* />
* ```
*/
export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
open,
title,
message,
items,
onConfirm,
onCancel,
loading = false,
confirmText = 'Confirm',
cancelText = 'Cancel',
}) => {
return (
<Dialog
open={open}
onClose={loading ? undefined : onCancel}
maxWidth="sm"
fullWidth
aria-labelledby="bulk-action-dialog-title"
>
<DialogTitle id="bulk-action-dialog-title">{title}</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
{message}
</Typography>
{items.length > 0 && (
<Box
sx={{
maxHeight: 200,
overflow: 'auto',
border: 1,
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
}}
>
<List dense>
{items.map((item, index) => (
<ListItem key={index}>
<ListItemText
primary={item}
primaryTypographyProps={{
variant: 'body2',
}}
/>
</ListItem>
))}
</List>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={onCancel}
disabled={loading}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
{cancelText}
</Button>
<Button
onClick={onConfirm}
disabled={loading}
variant="contained"
color="error"
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
confirmText
)}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,106 @@
/**
* @ai-summary Empty state component for when no data is available
* @ai-context Centered display with icon, title, description, and optional action
*/
import React from 'react';
import { Box, Typography, Button } from '@mui/material';
/**
* Action button configuration
*/
interface ActionConfig {
label: string;
onClick: () => void;
}
/**
* Props for EmptyState component
*/
export interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description: string;
action?: ActionConfig;
}
/**
* Empty state component for displaying when no data is available
*
* @example
* ```tsx
* <EmptyState
* icon={<InboxIcon fontSize="large" />}
* title="No Data"
* description="Start by adding your first item"
* action={{ label: 'Add Item', onClick: handleAdd }}
* />
* ```
*/
export const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
action,
}) => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
px: 2,
textAlign: 'center',
}}
>
{icon && (
<Box
sx={{
mb: 3,
color: 'text.secondary',
opacity: 0.5,
fontSize: 64,
}}
>
{icon}
</Box>
)}
<Typography
variant="h6"
component="h2"
sx={{
mb: 1,
fontWeight: 600,
color: 'text.primary',
}}
>
{title}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: action ? 3 : 0, maxWidth: 400 }}
>
{description}
</Typography>
{action && (
<Button
variant="contained"
onClick={action.onClick}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
{action.label}
</Button>
)}
</Box>
);
};

View File

@@ -0,0 +1,73 @@
/**
* @ai-summary Error state component with retry functionality
* @ai-context Centered error display with error message and retry button
*/
import React from 'react';
import { Box, Typography, Button, Alert } from '@mui/material';
import { Refresh as RefreshIcon } from '@mui/icons-material';
/**
* Props for ErrorState component
*/
export interface ErrorStateProps {
error: Error;
onRetry?: () => void;
}
/**
* Error state component for displaying errors with retry option
*
* @example
* ```tsx
* <ErrorState
* error={new Error('Failed to load data')}
* onRetry={refetch}
* />
* ```
*/
export const ErrorState: React.FC<ErrorStateProps> = ({ error, onRetry }) => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
px: 2,
}}
>
<Alert
severity="error"
sx={{
mb: 3,
maxWidth: 600,
width: '100%',
}}
>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
An error occurred
</Typography>
<Typography variant="body2">
{error.message || 'Something went wrong. Please try again.'}
</Typography>
</Alert>
{onRetry && (
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={onRetry}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
Retry
</Button>
)}
</Box>
);
};

View File

@@ -0,0 +1,105 @@
/**
* @ai-summary Toolbar component for bulk selection actions
* @ai-context Displays selection count with select all, clear, and custom action buttons
*/
import React from 'react';
import { Box, Toolbar, Typography, Button } from '@mui/material';
/**
* Props for SelectionToolbar component
*/
export interface SelectionToolbarProps {
selectedCount: number;
onSelectAll?: () => void;
onClear: () => void;
children?: React.ReactNode;
}
/**
* Toolbar component for displaying selection state and bulk actions
*
* @example
* ```tsx
* <SelectionToolbar
* selectedCount={3}
* onSelectAll={selectAll}
* onClear={reset}
* >
* <Button onClick={handleDelete}>Delete</Button>
* <Button onClick={handleExport}>Export</Button>
* </SelectionToolbar>
* ```
*/
export const SelectionToolbar: React.FC<SelectionToolbarProps> = ({
selectedCount,
onSelectAll,
onClear,
children,
}) => {
// Only show toolbar if items are selected
if (selectedCount === 0) {
return null;
}
return (
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
bgcolor: 'action.selected',
borderRadius: 1,
mb: 2,
minHeight: { xs: 56, sm: 64 },
}}
>
<Typography
variant="subtitle1"
component="div"
sx={{
flex: '1 1 100%',
fontWeight: 600,
}}
>
Selected: {selectedCount}
</Typography>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{onSelectAll && (
<Button
size="small"
onClick={onSelectAll}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
Select All
</Button>
)}
<Button
size="small"
onClick={onClear}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
Clear
</Button>
{children}
</Box>
</Toolbar>
);
};

View File

@@ -0,0 +1,30 @@
/**
* @ai-summary Exports for all admin shared components
* @ai-context Central export point for Phase 1A components
*/
export { AdminSectionHeader } from './AdminSectionHeader';
export type { AdminSectionHeaderProps, StatItem } from './AdminSectionHeader';
export { AdminDataGrid } from './AdminDataGrid';
export type { AdminDataGridProps, GridColumn } from './AdminDataGrid';
export { SelectionToolbar } from './SelectionToolbar';
export type { SelectionToolbarProps } from './SelectionToolbar';
export { BulkActionDialog } from './BulkActionDialog';
export type { BulkActionDialogProps } from './BulkActionDialog';
export { AuditLogPanel } from './AuditLogPanel';
export type { AuditLogPanelProps } from './AuditLogPanel';
export { AuditLogDrawer } from './AuditLogDrawer';
export type { AuditLogDrawerProps } from './AuditLogDrawer';
export { EmptyState } from './EmptyState';
export type { EmptyStateProps } from './EmptyState';
export { ErrorState } from './ErrorState';
export type { ErrorStateProps } from './ErrorState';
export { AdminSkeleton } from './AdminSkeleton';

View File

@@ -0,0 +1,140 @@
/**
* @ai-summary Hook for streaming audit logs with polling
* @ai-context Polls audit logs every 5 seconds with pagination support
*/
import { useState, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { adminApi } from '../api/admin.api';
import { AdminAuditLog } from '../types/admin.types';
/**
* Options for audit log stream
*/
interface AuditLogStreamOptions {
resourceType?: 'admin' | 'catalog' | 'station';
limit?: number;
pollIntervalMs?: number;
}
/**
* Pagination state
*/
interface PaginationState {
offset: number;
limit: number;
total: number;
}
/**
* Return type for audit log stream hook
*/
interface UseAuditLogStreamReturn {
logs: AdminAuditLog[];
loading: boolean;
error: any;
pagination: PaginationState;
hasMore: boolean;
nextPage: () => void;
prevPage: () => void;
refetch: () => void;
lastUpdated: Date;
}
/**
* Custom hook for streaming audit logs with polling
* Uses polling until SSE backend is available
*
* @example
* ```typescript
* const { logs, loading, nextPage, prevPage } = useAuditLogStream({
* resourceType: 'catalog',
* limit: 50,
* pollIntervalMs: 5000
* });
* ```
*/
export function useAuditLogStream(
options: AuditLogStreamOptions = {}
): UseAuditLogStreamReturn {
const { resourceType, limit = 50, pollIntervalMs = 5000 } = options;
const { isAuthenticated, isLoading: authLoading } = useAuth0();
const [offset, setOffset] = useState(0);
const [lastUpdated, setLastUpdated] = useState(new Date());
// Query for fetching audit logs
const {
data: rawLogs = [],
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['auditLogs', resourceType, offset, limit],
queryFn: async () => {
const logs = await adminApi.listAuditLogs();
setLastUpdated(new Date());
return logs;
},
enabled: isAuthenticated && !authLoading,
staleTime: pollIntervalMs,
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 1,
refetchInterval: pollIntervalMs,
refetchOnWindowFocus: false,
});
// Filter logs by resource type if specified
const filteredLogs = resourceType
? rawLogs.filter((log) => log.resourceType === resourceType)
: rawLogs;
// Apply pagination
const paginatedLogs = filteredLogs.slice(offset, offset + limit);
// Calculate pagination state
const pagination: PaginationState = {
offset,
limit,
total: filteredLogs.length,
};
const hasMore = offset + limit < filteredLogs.length;
/**
* Navigate to next page
*/
const nextPage = useCallback(() => {
if (hasMore) {
setOffset((prev) => prev + limit);
}
}, [hasMore, limit]);
/**
* Navigate to previous page
*/
const prevPage = useCallback(() => {
setOffset((prev) => Math.max(0, prev - limit));
}, [limit]);
/**
* Manual refetch wrapper
*/
const manualRefetch = useCallback(() => {
refetch();
}, [refetch]);
return {
logs: paginatedLogs,
loading: isLoading,
error,
pagination,
hasMore,
nextPage,
prevPage,
refetch: manualRefetch,
lastUpdated,
};
}

View File

@@ -0,0 +1,114 @@
/**
* @ai-summary Hook for managing bulk selection state across paginated data
* @ai-context Supports individual toggle, select all, and reset operations
*/
import { useState, useCallback, useMemo } from 'react';
/**
* Options for bulk selection hook
*/
interface UseBulkSelectionOptions<T> {
items: T[];
keyExtractor?: (item: T) => string;
}
/**
* Return type for bulk selection hook
*/
interface UseBulkSelectionReturn<T> {
selected: Set<string>;
toggleItem: (id: string) => void;
toggleAll: (items: T[]) => void;
isSelected: (id: string) => boolean;
reset: () => void;
count: number;
selectedItems: T[];
}
/**
* Custom hook for managing bulk selection state
* Supports selection across pagination boundaries
*
* @example
* ```typescript
* const { selected, toggleItem, toggleAll, reset, count } = useBulkSelection({ items: data });
* ```
*/
export function useBulkSelection<T extends { id: string }>(
options: UseBulkSelectionOptions<T>
): UseBulkSelectionReturn<T> {
const { items, keyExtractor = (item: T) => item.id } = options;
const [selected, setSelected] = useState<Set<string>>(new Set());
/**
* Toggle individual item selection
*/
const toggleItem = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
/**
* Toggle all items - if all are selected, deselect all; otherwise select all
* This supports "Select All" across pagination
*/
const toggleAll = useCallback((itemsToToggle: T[]) => {
setSelected((prev) => {
const itemIds = itemsToToggle.map(keyExtractor);
const allSelected = itemIds.every((id) => prev.has(id));
if (allSelected) {
// Deselect all items
const next = new Set(prev);
itemIds.forEach((id) => next.delete(id));
return next;
} else {
// Select all items
const next = new Set(prev);
itemIds.forEach((id) => next.add(id));
return next;
}
});
}, [keyExtractor]);
/**
* Check if item is selected
*/
const isSelected = useCallback(
(id: string) => selected.has(id),
[selected]
);
/**
* Clear all selections
*/
const reset = useCallback(() => {
setSelected(new Set());
}, []);
/**
* Get array of selected items from current items list
*/
const selectedItems = useMemo(() => {
return items.filter((item) => selected.has(keyExtractor(item)));
}, [items, selected, keyExtractor]);
return {
selected,
toggleItem,
toggleAll,
isSelected,
reset,
count: selected.size,
selectedItems,
};
}

View File

@@ -95,8 +95,8 @@ export const useModels = (makeId?: string) => {
return useQuery({ return useQuery({
queryKey: ['catalogModels', makeId], queryKey: ['catalogModels', makeId],
queryFn: () => adminApi.listModels(makeId), queryFn: () => adminApi.listModels(makeId as string),
enabled: isAuthenticated && !isLoading, enabled: Boolean(makeId) && isAuthenticated && !isLoading,
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
retry: 1, retry: 1,
@@ -156,8 +156,8 @@ export const useYears = (modelId?: string) => {
return useQuery({ return useQuery({
queryKey: ['catalogYears', modelId], queryKey: ['catalogYears', modelId],
queryFn: () => adminApi.listYears(modelId), queryFn: () => adminApi.listYears(modelId as string),
enabled: isAuthenticated && !isLoading, enabled: Boolean(modelId) && isAuthenticated && !isLoading,
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
retry: 1, retry: 1,
@@ -201,8 +201,8 @@ export const useTrims = (yearId?: string) => {
return useQuery({ return useQuery({
queryKey: ['catalogTrims', yearId], queryKey: ['catalogTrims', yearId],
queryFn: () => adminApi.listTrims(yearId), queryFn: () => adminApi.listTrims(yearId as string),
enabled: isAuthenticated && !isLoading, enabled: Boolean(yearId) && isAuthenticated && !isLoading,
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
retry: 1, retry: 1,
@@ -262,8 +262,8 @@ export const useEngines = (trimId?: string) => {
return useQuery({ return useQuery({
queryKey: ['catalogEngines', trimId], queryKey: ['catalogEngines', trimId],
queryFn: () => adminApi.listEngines(trimId), queryFn: () => adminApi.listEngines(trimId as string),
enabled: isAuthenticated && !isLoading, enabled: Boolean(trimId) && isAuthenticated && !isLoading,
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
retry: 1, retry: 1,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

82
test-bulk-delete.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
# Test script for bulk catalog delete endpoint
# Note: This is a test script to verify the endpoint structure
BASE_URL="http://localhost/api"
echo "Testing bulk delete catalog endpoint"
echo "===================================="
echo ""
# Test 1: Invalid entity type
echo "Test 1: Invalid entity type (should return 400)"
curl -X DELETE "${BASE_URL}/admin/catalog/invalid/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [1, 2, 3]}' \
-w "\nStatus: %{http_code}\n\n"
# Test 2: Empty IDs array
echo "Test 2: Empty IDs array (should return 400)"
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": []}' \
-w "\nStatus: %{http_code}\n\n"
# Test 3: Invalid IDs (negative numbers)
echo "Test 3: Invalid IDs - negative numbers (should return 400)"
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [1, -2, 3]}' \
-w "\nStatus: %{http_code}\n\n"
# Test 4: Invalid IDs (strings instead of numbers)
echo "Test 4: Invalid IDs - strings (should return 400)"
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": ["abc", "def"]}' \
-w "\nStatus: %{http_code}\n\n"
# Test 5: Valid request format (will fail without auth, but shows structure)
echo "Test 5: Valid request format - makes (needs auth)"
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [999, 998]}' \
-w "\nStatus: %{http_code}\n\n"
# Test 6: Valid request format - models (needs auth)
echo "Test 6: Valid request format - models (needs auth)"
curl -X DELETE "${BASE_URL}/admin/catalog/models/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [999, 998]}' \
-w "\nStatus: %{http_code}\n\n"
# Test 7: Valid request format - years (needs auth)
echo "Test 7: Valid request format - years (needs auth)"
curl -X DELETE "${BASE_URL}/admin/catalog/years/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [999, 998]}' \
-w "\nStatus: %{http_code}\n\n"
# Test 8: Valid request format - trims (needs auth)
echo "Test 8: Valid request format - trims (needs auth)"
curl -X DELETE "${BASE_URL}/admin/catalog/trims/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [999, 998]}' \
-w "\nStatus: %{http_code}\n\n"
# Test 9: Valid request format - engines (needs auth)
echo "Test 9: Valid request format - engines (needs auth)"
curl -X DELETE "${BASE_URL}/admin/catalog/engines/bulk-delete" \
-H "Content-Type: application/json" \
-d '{"ids": [999, 998]}' \
-w "\nStatus: %{http_code}\n\n"
echo "===================================="
echo "Test script complete"
echo ""
echo "Expected results:"
echo "- Tests 1-4: Should return 400 (Bad Request)"
echo "- Tests 5-9: Should return 401 (Unauthorized) without auth token"
echo ""
echo "Note: Full testing requires admin authentication token"