feat: add pending vehicle association resolution UI (refs #160)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 8m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 8m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Backend: Add authenticated endpoints for pending association CRUD (GET/POST/DELETE /api/email-ingestion/pending). Service methods for resolving (creates fuel/maintenance record) and dismissing associations. Frontend: New email-ingestion feature with types, API client, hooks, PendingAssociationBanner (dashboard), PendingAssociationList, and ResolveAssociationDialog. Mobile-first responsive with 44px touch targets and full-screen dialogs on small screens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ import { userImportRoutes } from './features/user-import';
|
||||
import { ownershipCostsRoutes } from './features/ownership-costs';
|
||||
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
|
||||
import { ocrRoutes } from './features/ocr';
|
||||
import { emailIngestionWebhookRoutes } from './features/email-ingestion';
|
||||
import { emailIngestionWebhookRoutes, emailIngestionRoutes } from './features/email-ingestion';
|
||||
import { pool } from './core/config/database';
|
||||
import { configRoutes } from './core/config/config.routes';
|
||||
|
||||
@@ -154,6 +154,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(donationsRoutes, { prefix: '/api' });
|
||||
await app.register(webhooksRoutes, { prefix: '/api' });
|
||||
await app.register(emailIngestionWebhookRoutes, { prefix: '/api' });
|
||||
await app.register(emailIngestionRoutes, { prefix: '/api' });
|
||||
await app.register(ocrRoutes, { prefix: '/api' });
|
||||
await app.register(configRoutes, { prefix: '/api' });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @ai-summary Controller for Resend inbound email webhook
|
||||
* @ai-context Verifies signatures, checks idempotency, queues emails for async processing
|
||||
* @ai-summary Controller for Resend inbound email webhook and user-facing pending association endpoints
|
||||
* @ai-context Webhook handler (public) + pending association CRUD (JWT-authenticated)
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
@@ -21,6 +21,105 @@ export class EmailIngestionController {
|
||||
this.service = new EmailIngestionService();
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Pending Association Endpoints (JWT-authenticated)
|
||||
// ========================
|
||||
|
||||
async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const associations = await this.repository.getPendingAssociations(userId);
|
||||
return reply.code(200).send(associations);
|
||||
} catch (error: any) {
|
||||
logger.error('Error listing pending associations', { error: error.message, userId: (request as any).user?.sub });
|
||||
return reply.code(500).send({ error: 'Failed to list pending associations' });
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const count = await this.repository.getPendingAssociationCount(userId);
|
||||
return reply.code(200).send({ count });
|
||||
} catch (error: any) {
|
||||
logger.error('Error counting pending associations', { error: error.message, userId: (request as any).user?.sub });
|
||||
return reply.code(500).send({ error: 'Failed to count pending associations' });
|
||||
}
|
||||
}
|
||||
|
||||
async resolveAssociation(
|
||||
request: FastifyRequest<{ Params: { id: string }; Body: { vehicleId: string } }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
const { vehicleId } = request.body;
|
||||
|
||||
if (!vehicleId || typeof vehicleId !== 'string') {
|
||||
return reply.code(400).send({ error: 'vehicleId is required' });
|
||||
}
|
||||
|
||||
const result = await this.service.resolveAssociation(id, vehicleId, userId);
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
const userId = (request as any).user?.sub;
|
||||
logger.error('Error resolving pending association', {
|
||||
error: error.message,
|
||||
associationId: request.params.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (error.message === 'Pending association not found' || error.message === 'Vehicle not found') {
|
||||
return reply.code(404).send({ error: error.message });
|
||||
}
|
||||
if (error.message === 'Unauthorized') {
|
||||
return reply.code(403).send({ error: 'Not authorized' });
|
||||
}
|
||||
if (error.message === 'Association already resolved') {
|
||||
return reply.code(409).send({ error: error.message });
|
||||
}
|
||||
|
||||
return reply.code(500).send({ error: 'Failed to resolve association' });
|
||||
}
|
||||
}
|
||||
|
||||
async dismissAssociation(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
|
||||
await this.service.dismissAssociation(id, userId);
|
||||
return reply.code(204).send();
|
||||
} catch (error: any) {
|
||||
const userId = (request as any).user?.sub;
|
||||
logger.error('Error dismissing pending association', {
|
||||
error: error.message,
|
||||
associationId: request.params.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (error.message === 'Pending association not found') {
|
||||
return reply.code(404).send({ error: error.message });
|
||||
}
|
||||
if (error.message === 'Unauthorized') {
|
||||
return reply.code(403).send({ error: 'Not authorized' });
|
||||
}
|
||||
if (error.message === 'Association already resolved') {
|
||||
return reply.code(409).send({ error: error.message });
|
||||
}
|
||||
|
||||
return reply.code(500).send({ error: 'Failed to dismiss association' });
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Webhook Endpoint (Public)
|
||||
// ========================
|
||||
|
||||
async handleInboundWebhook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const rawBody = (request as any).rawBody;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* @ai-summary Resend inbound webhook route registration
|
||||
* @ai-context Public endpoint (no JWT auth) with rawBody for signature verification
|
||||
* @ai-summary Resend inbound webhook + user-facing pending association routes
|
||||
* @ai-context Public webhook (no JWT) + authenticated CRUD for pending vehicle associations
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { EmailIngestionController } from './email-ingestion.controller';
|
||||
|
||||
/** Public webhook route - no JWT auth, uses Svix signature verification */
|
||||
export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const controller = new EmailIngestionController();
|
||||
|
||||
@@ -22,3 +23,38 @@ export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) =
|
||||
controller.handleInboundWebhook.bind(controller)
|
||||
);
|
||||
};
|
||||
|
||||
/** Authenticated user-facing routes for pending vehicle associations */
|
||||
export const emailIngestionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const controller = new EmailIngestionController();
|
||||
|
||||
// GET /api/email-ingestion/pending - List pending associations for authenticated user
|
||||
fastify.get('/email-ingestion/pending', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getPendingAssociations.bind(controller),
|
||||
});
|
||||
|
||||
// GET /api/email-ingestion/pending/count - Get count of pending associations
|
||||
fastify.get('/email-ingestion/pending/count', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getPendingAssociationCount.bind(controller),
|
||||
});
|
||||
|
||||
// POST /api/email-ingestion/pending/:id/resolve - Resolve by selecting vehicle
|
||||
fastify.post<{ Params: { id: string }; Body: { vehicleId: string } }>(
|
||||
'/email-ingestion/pending/:id/resolve',
|
||||
{
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.resolveAssociation.bind(controller),
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/email-ingestion/pending/:id - Dismiss/discard a pending association
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/email-ingestion/pending/:id',
|
||||
{
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.dismissAssociation.bind(controller),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -194,6 +194,33 @@ export class EmailIngestionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingAssociationById(associationId: string): Promise<PendingVehicleAssociation | null> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM pending_vehicle_associations WHERE id = $1`,
|
||||
[associationId]
|
||||
);
|
||||
return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching pending association by id', { error, associationId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingAssociationCount(userId: string): Promise<number> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
`SELECT COUNT(*)::int AS count FROM pending_vehicle_associations
|
||||
WHERE user_id = $1 AND status = 'pending'`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows[0]?.count ?? 0;
|
||||
} catch (error) {
|
||||
logger.error('Error counting pending associations', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingAssociations(userId: string): Promise<PendingVehicleAssociation[]> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
|
||||
@@ -542,6 +542,67 @@ export class EmailIngestionService {
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Public Resolution API
|
||||
// ========================
|
||||
|
||||
/**
|
||||
* Resolve a pending vehicle association by creating the record with the selected vehicle.
|
||||
* Called from the user-facing API when a multi-vehicle user picks a vehicle.
|
||||
*/
|
||||
async resolveAssociation(
|
||||
associationId: string,
|
||||
vehicleId: string,
|
||||
userId: string
|
||||
): Promise<{ recordId: string; recordType: EmailRecordType }> {
|
||||
const association = await this.repository.getPendingAssociationById(associationId);
|
||||
if (!association) {
|
||||
throw new Error('Pending association not found');
|
||||
}
|
||||
if (association.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (association.status !== 'pending') {
|
||||
throw new Error('Association already resolved');
|
||||
}
|
||||
|
||||
// Verify vehicle belongs to user
|
||||
const vehicles = await this.vehiclesRepository.findByUserId(userId);
|
||||
const vehicle = vehicles.find(v => v.id === vehicleId);
|
||||
if (!vehicle) {
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
// Create the record
|
||||
const recordId = await this.createRecord(userId, vehicleId, association.recordType, association.extractedData);
|
||||
|
||||
// Mark as resolved
|
||||
await this.repository.resolvePendingAssociation(associationId, 'resolved');
|
||||
|
||||
logger.info('Pending association resolved', { associationId, vehicleId, userId, recordType: association.recordType, recordId });
|
||||
|
||||
return { recordId, recordType: association.recordType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a pending vehicle association without creating a record.
|
||||
*/
|
||||
async dismissAssociation(associationId: string, userId: string): Promise<void> {
|
||||
const association = await this.repository.getPendingAssociationById(associationId);
|
||||
if (!association) {
|
||||
throw new Error('Pending association not found');
|
||||
}
|
||||
if (association.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (association.status !== 'pending') {
|
||||
throw new Error('Association already resolved');
|
||||
}
|
||||
|
||||
await this.repository.resolvePendingAssociation(associationId, 'expired');
|
||||
logger.info('Pending association dismissed', { associationId, userId });
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Record Creation
|
||||
// ========================
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @ai-context Exports webhook routes, services, and types for Resend inbound email processing
|
||||
*/
|
||||
|
||||
export { emailIngestionWebhookRoutes } from './api/email-ingestion.routes';
|
||||
export { emailIngestionWebhookRoutes, emailIngestionRoutes } from './api/email-ingestion.routes';
|
||||
export { EmailIngestionService } from './domain/email-ingestion.service';
|
||||
export { EmailIngestionRepository } from './data/email-ingestion.repository';
|
||||
export { ReceiptClassifier } from './domain/receipt-classifier';
|
||||
|
||||
Reference in New Issue
Block a user