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:
Sase Dev
2026-02-17 18:09:07 +00:00
parent dcbeb83ccc
commit f2126754a6
42 changed files with 1307 additions and 223 deletions

View 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 };
}
}

View 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 {}

View 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;
}
}