INSIGHT-MVP/packages/crm-service/src/events/activity-due-soon.scheduler.ts
Thomas Reitz 48df3c3144 feat(crm): Phase 1 backend schema expansion + frontend integration
Backend (CRM-Expert Phase 1):
- New enums: ContactSource, EntityStatus, CompanySize, OwnerRole,
  LostReason, EmailType, PhoneType
- Contact: add linkedinUrl, birthday, source, department, status
- Company: add vatId, taxId, tradeRegisterNumber, registerCourt,
  companySize, deliveryAddress, dataEnrichedAt/Source, status
- Deal: add lostReason + lostReasonText (required when status=LOST)
- Multi-value emails/phones tables (contact_emails, contact_phones)
- Owner m:n model (contact_owners, company_owners, deal_owners)
- Redis Pub/Sub CRM events (crm.contact.created, crm.deal.won, etc.)
- Activity due_soon scheduler (cron every 15 min)
- SQL migration with data migration for existing records

Frontend integration:
- types.ts: all new enums, interfaces, label maps
- api.ts: owner CRUD endpoints (add/remove for contacts/companies/deals)
- hooks.ts: 6 new owner mutation hooks
- ContactFormModal: LinkedIn, birthday, source, department, status fields
- ContactDetailPage: display new fields (LinkedIn, department, birthday,
  source, status badge)
- CompanyDetailPage: display vatId, taxId, trade register, company size,
  delivery address, data enrichment info
- DealFormModal: lost reason dropdown + text (shown when status=LOST)
- DealDetailPage: display lost reason with label
- CompaniesPage: EntityStatus-aware status dots (ACTIVE/INACTIVE/BLOCKED)
- ActivityType: add FOLLOWUP to all label maps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:56:41 +01:00

85 lines
2.6 KiB
TypeScript

// ============================================================
// Scheduler: Activity Due Soon - alle 15 Minuten
// ============================================================
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { RedisService } from '../redis/redis.service';
import { CrmEventPublisher } from './crm-event-publisher.service';
@Injectable()
export class ActivityDueSoonScheduler {
private readonly logger = new Logger(ActivityDueSoonScheduler.name);
private static readonly LOCK_KEY = 'lock:activity-due-soon';
private static readonly LOCK_TTL = 600; // 10 Minuten
constructor(
private readonly prisma: CrmPrismaService,
private readonly redis: RedisService,
private readonly eventPublisher: CrmEventPublisher,
) {}
@Cron('0 */15 * * * *')
async checkDueSoon(): Promise<void> {
// Distributed Lock um parallele Ausfuehrung zu verhindern
const lockAcquired = await this.redis.setNx(
ActivityDueSoonScheduler.LOCK_KEY,
process.pid.toString(),
ActivityDueSoonScheduler.LOCK_TTL,
);
if (!lockAcquired) {
this.logger.debug('Activity Due Soon Check: Lock bereits vergeben, ueberspringe.');
return;
}
try {
const now = new Date();
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
// Activities die in den naechsten 24h faellig sind und nicht completed
const dueActivities = await this.prisma.activity.findMany({
where: {
scheduledAt: {
gte: now,
lte: in24h,
},
completedAt: null,
},
select: {
id: true,
tenantId: true,
scheduledAt: true,
contactId: true,
companyId: true,
},
take: 100, // Batch-Limit
});
if (dueActivities.length === 0) {
this.logger.debug('Keine faelligen Activities gefunden.');
return;
}
this.logger.log(
`${dueActivities.length} faellige Activities gefunden, publiziere Events...`,
);
for (const activity of dueActivities) {
await this.eventPublisher.activityDueSoon(
activity.tenantId,
activity.id,
{
scheduledAt: activity.scheduledAt?.toISOString() ?? '',
contactId: activity.contactId ?? undefined,
},
);
}
this.logger.log(`${dueActivities.length} activity.due_soon Events publiziert.`);
} catch (err) {
this.logger.error(`Fehler im Activity Due Soon Scheduler: ${String(err)}`);
}
}
}