feat: add OEM code copy tracking, PostHog analytics, Postal email integration
- Add oem_code_copies table and analytics module for tracking part code copies - Integrate PostHog for frontend product analytics (VIN decode, OEM copy events) - Switch email service from stub to Postal API with proper error handling - Add copy button to parts panel with clipboard + server-side logging - Add admin copy-logs page with filtering and top-copied-codes view - Add VIN report endpoint for users to flag unrecognized chassis numbers - Add Postal email env vars to config schema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
29
apps/api/src/analytics/analytics.controller.ts
Normal file
29
apps/api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { AnalyticsService } from "./analytics.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("analytics")
|
||||
export class AnalyticsController {
|
||||
constructor(private analyticsService: AnalyticsService) {}
|
||||
|
||||
@Post("oem-copy")
|
||||
async trackOemCopy(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body()
|
||||
body: {
|
||||
oemCode: string;
|
||||
partId?: string;
|
||||
vehicleId?: string;
|
||||
categoryId?: string;
|
||||
},
|
||||
) {
|
||||
await this.analyticsService.trackOemCodeCopy({
|
||||
userId: user.id,
|
||||
oemCode: body.oemCode,
|
||||
partId: body.partId,
|
||||
vehicleId: body.vehicleId,
|
||||
categoryId: body.categoryId,
|
||||
});
|
||||
return { tracked: true };
|
||||
}
|
||||
}
|
||||
10
apps/api/src/analytics/analytics.module.ts
Normal file
10
apps/api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AnalyticsController } from "./analytics.controller";
|
||||
import { AnalyticsService } from "./analytics.service";
|
||||
|
||||
@Module({
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
85
apps/api/src/analytics/analytics.service.ts
Normal file
85
apps/api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { count, desc, eq, gte, sql } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { oemCodeCopies, users } from "../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(@Inject(DATABASE) private db: Database) {}
|
||||
|
||||
async trackOemCodeCopy(data: {
|
||||
userId: string;
|
||||
oemCode: string;
|
||||
partId?: string;
|
||||
vehicleId?: string;
|
||||
categoryId?: string;
|
||||
}) {
|
||||
try {
|
||||
await this.db.insert(oemCodeCopies).values({
|
||||
userId: data.userId,
|
||||
oemCode: data.oemCode,
|
||||
partId: data.partId ?? null,
|
||||
vehicleId: data.vehicleId ?? null,
|
||||
categoryId: data.categoryId ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to track OEM code copy", (err as Error).stack);
|
||||
}
|
||||
}
|
||||
|
||||
async getCopyLogs(page = 1, limit = 50, userId?: string) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = userId ? eq(oemCodeCopies.userId, userId) : undefined;
|
||||
|
||||
const [items, totalResult] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: oemCodeCopies.id,
|
||||
userId: oemCodeCopies.userId,
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
oemCode: oemCodeCopies.oemCode,
|
||||
partId: oemCodeCopies.partId,
|
||||
vehicleId: oemCodeCopies.vehicleId,
|
||||
categoryId: oemCodeCopies.categoryId,
|
||||
createdAt: oemCodeCopies.createdAt,
|
||||
})
|
||||
.from(oemCodeCopies)
|
||||
.innerJoin(users, eq(oemCodeCopies.userId, users.id))
|
||||
.where(conditions)
|
||||
.orderBy(desc(oemCodeCopies.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
this.db.select({ count: count() }).from(oemCodeCopies).where(conditions),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: totalResult[0].count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalResult[0].count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async getTopCopiedCodes(days = 30, limit = 20) {
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const result = await this.db
|
||||
.select({
|
||||
oemCode: oemCodeCopies.oemCode,
|
||||
copyCount: count().as("copy_count"),
|
||||
uniqueUsers: sql<number>`count(distinct ${oemCodeCopies.userId})`.as("unique_users"),
|
||||
})
|
||||
.from(oemCodeCopies)
|
||||
.where(gte(oemCodeCopies.createdAt, since))
|
||||
.groupBy(oemCodeCopies.oemCode)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(limit);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user