grabbit f557c06771 feat: complete database schema migration to UUID primary keys
## Database Migrations (18 new)
- Migrate all primary keys from SERIAL to UUID
- Add soft delete (deleted_at) to all 19 entities
- Add missing indexes for performance optimization
- Add CHECK constraints for data validation
- Add user audit fields (last_login_at, timezone, locale)
- Add weather station location fields (latitude, longitude, elevation)
- Add foreign key relationships (CameraDevice→Device, ValidatedEvent→WeatherStation)
- Prepare private key encryption fields

## Backend Entity Updates
- All entities updated with UUID primary keys
- Added @DeleteDateColumn for soft delete support
- Updated relations and foreign key types

## Backend Service/Controller Updates
- Changed ID parameters from number to string (UUID)
- Removed ParseIntPipe from controllers
- Updated TypeORM queries for string IDs

## Frontend Updates
- Updated all service interfaces to use string IDs
- Fixed CameraDevice.location as JSONB object
- Updated weather.ts with new fields (elevation, timezone)
- Added Supabase integration hooks and lib
- Fixed chart components for new data structure

## Cleanup
- Removed deprecated .claude/agents configuration files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 03:33:26 +08:00

294 lines
9.3 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindManyOptions } from 'typeorm';
import { SubscriptionPlan } from '../entities/subscription-plan.entity';
import { UserSubscription } from '../entities/user-subscription.entity';
import { SubscriptionHistory } from '../entities/subscription-history.entity';
import { PaymentRecord } from '../entities/payment-record.entity';
export interface SubscriptionQuery {
page?: number;
limit?: number;
status?: string;
planId?: string;
userId?: string;
}
export interface PlanStats {
planId: string;
planName: string;
totalSubscriptions: number;
activeSubscriptions: number;
revenue: number;
popularityRank: number;
}
@Injectable()
export class SubscriptionService {
constructor(
@InjectRepository(SubscriptionPlan)
private subscriptionPlanRepository: Repository<SubscriptionPlan>,
@InjectRepository(UserSubscription)
private userSubscriptionRepository: Repository<UserSubscription>,
@InjectRepository(SubscriptionHistory)
private subscriptionHistoryRepository: Repository<SubscriptionHistory>,
@InjectRepository(PaymentRecord)
private paymentRecordRepository: Repository<PaymentRecord>,
) {}
// Subscription Plans
async getAllPlans() {
const plans = await this.subscriptionPlanRepository.find({
where: { isActive: true },
order: {
price: 'ASC',
isPopular: 'DESC',
},
});
return plans;
}
async getPlan(id: string) {
const plan = await this.subscriptionPlanRepository.findOne({
where: { id },
});
if (!plan) {
throw new NotFoundException(`Subscription plan with ID ${id} not found`);
}
return plan;
}
async getPlanByPlanId(planId: string) {
const plan = await this.subscriptionPlanRepository.findOne({
where: { planId },
});
if (!plan) {
throw new NotFoundException(`Subscription plan with plan ID ${planId} not found`);
}
return plan;
}
// User Subscriptions
async getUserSubscriptions(query: SubscriptionQuery = {}) {
const { page = 1, limit = 10, status, planId, userId } = query;
const skip = (page - 1) * limit;
const queryBuilder = this.userSubscriptionRepository
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.subscriptionPlan', 'plan')
.leftJoinAndSelect('subscription.userProfile', 'user')
.orderBy('subscription.createdAt', 'DESC')
.skip(skip)
.take(limit);
if (status) {
queryBuilder.andWhere('subscription.status = :status', { status });
}
if (planId) {
queryBuilder.andWhere('plan.planId = :planId', { planId });
}
if (userId) {
queryBuilder.andWhere('subscription.userProfileId = :userId', { userId });
}
const [subscriptions, total] = await queryBuilder.getManyAndCount();
return {
subscriptions,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
async getUserSubscription(userId: string) {
const subscription = await this.userSubscriptionRepository
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.subscriptionPlan', 'plan')
.leftJoinAndSelect('subscription.userProfile', 'user')
.where('subscription.userProfileId = :userId', { userId })
.andWhere('subscription.status IN (:...statuses)', { statuses: ['active', 'trialing'] })
.getOne();
return subscription;
}
async createSubscription(userId: string, planId: string, subscriptionData: Partial<UserSubscription>) {
const plan = await this.getPlanByPlanId(planId);
// Check if user already has an active subscription
const existingSubscription = await this.getUserSubscription(userId);
if (existingSubscription) {
throw new BadRequestException('User already has an active subscription');
}
const subscription = this.userSubscriptionRepository.create({
userProfileId: userId,
subscriptionPlanId: plan.id,
status: 'active',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
...subscriptionData,
});
const savedSubscription = await this.userSubscriptionRepository.save(subscription);
// Create subscription history record
await this.createSubscriptionHistory(savedSubscription.id, 'created', null, 'active', {
planId: plan.planId,
createdBy: 'system',
});
return savedSubscription;
}
async updateSubscriptionStatus(subscriptionId: string, status: string, metadata?: any) {
const subscription = await this.userSubscriptionRepository.findOne({
where: { id: subscriptionId },
});
if (!subscription) {
throw new NotFoundException(`Subscription with ID ${subscriptionId} not found`);
}
const oldStatus = subscription.status;
subscription.status = status as any;
subscription.updatedAt = new Date();
if (status === 'canceled') {
subscription.canceledAt = new Date();
}
const updatedSubscription = await this.userSubscriptionRepository.save(subscription);
// Create history record
await this.createSubscriptionHistory(subscriptionId, 'updated', oldStatus, status, metadata);
return updatedSubscription;
}
// Subscription History
async createSubscriptionHistory(
subscriptionId: string,
action: string,
oldStatus: string | null,
newStatus: string | null,
metadata?: any,
) {
const history = new SubscriptionHistory();
history.userSubscriptionId = subscriptionId;
history.action = action as any;
history.oldStatus = oldStatus || undefined;
history.newStatus = newStatus || undefined;
history.metadata = metadata;
return await this.subscriptionHistoryRepository.save(history);
}
async getSubscriptionHistory(subscriptionId: string) {
return await this.subscriptionHistoryRepository.find({
where: { userSubscriptionId: subscriptionId },
order: { createdAt: 'DESC' },
});
}
// Payment Records
async createPaymentRecord(subscriptionId: string, paymentData: Partial<PaymentRecord>) {
const payment = this.paymentRecordRepository.create({
userSubscriptionId: subscriptionId,
...paymentData,
});
return await this.paymentRecordRepository.save(payment);
}
async getPaymentRecords(subscriptionId: string) {
return await this.paymentRecordRepository.find({
where: { userSubscriptionId: subscriptionId },
order: { createdAt: 'DESC' },
});
}
// Statistics and Analytics
async getSubscriptionStats() {
const totalPlans = await this.subscriptionPlanRepository.count({ where: { isActive: true } });
const totalSubscriptions = await this.userSubscriptionRepository.count();
const activeSubscriptions = await this.userSubscriptionRepository.count({
where: { status: 'active' },
});
const trialSubscriptions = await this.userSubscriptionRepository.count({
where: { status: 'trialing' },
});
const canceledSubscriptions = await this.userSubscriptionRepository.count({
where: { status: 'canceled' },
});
// Calculate total revenue from successful payments
const revenueResult = await this.paymentRecordRepository
.createQueryBuilder('payment')
.select('SUM(payment.amount)', 'total')
.where('payment.status = :status', { status: 'succeeded' })
.getRawOne();
const totalRevenue = parseFloat(revenueResult?.total || '0');
// Calculate monthly recurring revenue (MRR)
const mrrResult = await this.userSubscriptionRepository
.createQueryBuilder('subscription')
.leftJoin('subscription.subscriptionPlan', 'plan')
.select('SUM(plan.price)', 'mrr')
.where('subscription.status IN (:...statuses)', { statuses: ['active', 'trialing'] })
.andWhere('plan.interval = :interval', { interval: 'month' })
.getRawOne();
const monthlyRecurringRevenue = parseFloat(mrrResult?.mrr || '0');
return {
totalPlans,
totalSubscriptions,
activeSubscriptions,
trialSubscriptions,
canceledSubscriptions,
totalRevenue,
monthlyRecurringRevenue,
};
}
async getPlanStats(): Promise<PlanStats[]> {
const plans = await this.subscriptionPlanRepository
.createQueryBuilder('plan')
.leftJoin('plan.userSubscriptions', 'subscription')
.leftJoin('subscription.paymentRecords', 'payment', 'payment.status = :paymentStatus', { paymentStatus: 'succeeded' })
.select([
'plan.planId as planId',
'plan.name as planName',
'COUNT(subscription.id) as totalSubscriptions',
'COUNT(CASE WHEN subscription.status = \'active\' THEN 1 END) as activeSubscriptions',
'COALESCE(SUM(payment.amount), 0) as revenue',
])
.where('plan.isActive = :active', { active: true })
.groupBy('plan.id, plan.planId, plan.name')
.orderBy('totalSubscriptions', 'DESC')
.getRawMany();
return plans.map((plan, index) => ({
planId: plan.planid,
planName: plan.planname,
totalSubscriptions: parseInt(plan.totalsubscriptions),
activeSubscriptions: parseInt(plan.activesubscriptions),
revenue: parseFloat(plan.revenue),
popularityRank: index + 1,
}));
}
}