@@ -0,0 +1,14 @@
|
||||
import { IsArray, IsOptional, IsString, ArrayMaxSize } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class SendRemindersDto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Tabs to remind, including ones still in their cooldown. Omit to remind every eligible customer.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1000)
|
||||
@IsString({ each: true })
|
||||
tabIds?: string[];
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
import { RemindersService } from './reminders.service';
|
||||
import { SendRemindersDto } from './dto/send-reminders.dto';
|
||||
|
||||
@ApiTags('Reminders')
|
||||
@ApiBearerAuth()
|
||||
@@ -14,8 +15,8 @@ export class RemindersController {
|
||||
constructor(private service: RemindersService) {}
|
||||
|
||||
@Get('candidates')
|
||||
@ApiOperation({ summary: 'List tabs eligible for payment reminders' })
|
||||
@ApiResponse({ status: 200, description: 'List of eligible tabs with customer email and amount' })
|
||||
@ApiOperation({ summary: 'List customers with an unpaid tab and their reminder history' })
|
||||
@ApiResponse({ status: 200, description: 'Unpaid tabs with email, amount, reminder count and eligibility' })
|
||||
async listCandidates(@Param('venueId') venueId: string) {
|
||||
return this.service.listCandidates(venueId);
|
||||
}
|
||||
@@ -27,15 +28,16 @@ export class RemindersController {
|
||||
@ApiResponse({ status: 201, description: 'Reminders sent' })
|
||||
@ApiResponse({ status: 400, description: 'Resend not configured' })
|
||||
@ApiResponse({ status: 409, description: 'Reminders are already being sent' })
|
||||
async send(@Param('venueId') venueId: string) {
|
||||
return this.service.sendReminders(venueId);
|
||||
async send(@Param('venueId') venueId: string, @Body() dto: SendRemindersDto) {
|
||||
return this.service.sendReminders(venueId, dto.tabIds);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get reminder history' })
|
||||
@ApiQuery({ name: 'customerName', required: false, description: 'Only reminders sent to this customer' })
|
||||
@ApiResponse({ status: 200, description: 'List of previously sent reminders' })
|
||||
async history(@Param('venueId') venueId: string) {
|
||||
return this.service.history(venueId);
|
||||
async history(@Param('venueId') venueId: string, @Query('customerName') customerName?: string) {
|
||||
return this.service.history(venueId, customerName);
|
||||
}
|
||||
|
||||
@Post('test/:plannedTabId')
|
||||
|
||||
@@ -2,7 +2,9 @@ import { ConflictException } from '@nestjs/common';
|
||||
import { EmailTemplatesService } from '../email/email-templates.service';
|
||||
import { RemindersService } from './reminders.service';
|
||||
|
||||
function createService(items: unknown, { plannedName = 'Bram' } = {}) {
|
||||
type SentRow = { customerName: string; _count: { _all: number }; _max: { sentAt: Date | null } };
|
||||
|
||||
function createService(items: unknown, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) {
|
||||
const prisma = {
|
||||
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
|
||||
openTabSnapshot: {
|
||||
@@ -11,7 +13,7 @@ function createService(items: unknown, { plannedName = 'Bram' } = {}) {
|
||||
]),
|
||||
},
|
||||
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) },
|
||||
tabReminder: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn() },
|
||||
tabReminder: { groupBy: jest.fn().mockResolvedValue(sent), createMany: jest.fn() },
|
||||
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
|
||||
};
|
||||
const service = new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
|
||||
@@ -105,9 +107,51 @@ describe('RemindersService.sendReminders', () => {
|
||||
|
||||
it('lets database errors surface instead of re-sending to everyone', async () => {
|
||||
const { service, prisma } = createService(null);
|
||||
prisma.tabReminder.findMany.mockRejectedValue(new Error('db down'));
|
||||
prisma.tabReminder.groupBy.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.sendReminders('v1')).rejects.toThrow('db down');
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips customers in their cooldown unless they are picked by hand', async () => {
|
||||
const daysAgo = (d: number) => new Date(Date.now() - d * 24 * 60 * 60 * 1000);
|
||||
const { service } = createService(null, {
|
||||
sent: [{ customerName: 'bram', _count: { _all: 2 }, _max: { sentAt: daysAgo(1) } }],
|
||||
});
|
||||
|
||||
const [candidate] = (await service.listCandidates('v1')).candidates;
|
||||
expect(candidate).toMatchObject({ eligible: false, reminderCount: 2 });
|
||||
|
||||
await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 0 });
|
||||
await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 1 });
|
||||
});
|
||||
|
||||
it('only sends to the picked tabs', async () => {
|
||||
const { service } = createService(null);
|
||||
|
||||
await expect(service.sendReminders('v1', ['other'])).resolves.toMatchObject({ sent: 0, total: 0 });
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lists customers without an email as not eligible', async () => {
|
||||
const { service, prisma } = createService(null);
|
||||
prisma.plannedTab.findMany.mockResolvedValue([]);
|
||||
|
||||
const { candidates } = await service.listCandidates('v1');
|
||||
|
||||
expect(candidates).toEqual([expect.objectContaining({ email: null, eligible: false, reminderCount: 0 })]);
|
||||
await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 0 });
|
||||
});
|
||||
|
||||
it('uses a new idempotency key for a deliberate repeat', async () => {
|
||||
const { service, prisma } = createService(null);
|
||||
await service.sendReminders('v1', ['t1']);
|
||||
prisma.tabReminder.groupBy.mockResolvedValue([
|
||||
{ customerName: 'Bram', _count: { _all: 1 }, _max: { sentAt: new Date() } },
|
||||
]);
|
||||
|
||||
await service.sendReminders('v1', ['t1']);
|
||||
|
||||
expect(requests[0].headers['Idempotency-Key']).not.toBe(requests[1].headers['Idempotency-Key']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
|
||||
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again
|
||||
// once this many days have passed since their last reminder.
|
||||
const REMINDER_COOLDOWN_DAYS = 7;
|
||||
// Tabs stay open per customer (closing only settles them), so a customer is eligible again
|
||||
// once this many days have passed since their last reminder. Admins can still pick them by hand.
|
||||
export const REMINDER_COOLDOWN_DAYS = 7;
|
||||
const COOLDOWN_MS = REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Resend accepts at most 100 emails per batch request
|
||||
const BATCH_SIZE = 100;
|
||||
@@ -15,12 +16,21 @@ const BATCH_SIZE = 100;
|
||||
type Candidate = {
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
email: string;
|
||||
// Null when the planned customer has no email address: shown, but can't be reminded
|
||||
email: string | null;
|
||||
amountInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
reminderCount: number;
|
||||
lastReminderAt: Date | null;
|
||||
// When the cooldown ends; null when they have never been reminded
|
||||
nextEligibleAt: Date | null;
|
||||
// Has an email and is not within the cooldown: selected by default
|
||||
eligible: boolean;
|
||||
};
|
||||
|
||||
type Recipient = Candidate & { email: string };
|
||||
|
||||
@Injectable()
|
||||
export class RemindersService {
|
||||
private resendApiKey: string;
|
||||
@@ -36,7 +46,8 @@ export class RemindersService {
|
||||
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
||||
}
|
||||
|
||||
async listCandidates(venueId: string): Promise<{ candidates: Candidate[] }> {
|
||||
/** Every customer with an unpaid open tab, with their reminder track record. */
|
||||
async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> {
|
||||
const ownerships = await this.prisma.tabOwnership.findMany({
|
||||
where: { venueId },
|
||||
});
|
||||
@@ -54,18 +65,32 @@ export class RemindersService {
|
||||
});
|
||||
const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!]));
|
||||
|
||||
const alreadySent = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
|
||||
select: { tabId: true },
|
||||
// Reminders are tracked per customer: names differing only in case or spacing are one person
|
||||
const sentPerName = await this.prisma.tabReminder.groupBy({
|
||||
by: ['customerName'],
|
||||
where: { venueId },
|
||||
_count: { _all: true },
|
||||
_max: { sentAt: true },
|
||||
});
|
||||
const sentTabIds = new Set(alreadySent.map((r) => r.tabId));
|
||||
const sentByKey = new Map<string, { count: number; last: Date | null }>();
|
||||
for (const row of sentPerName) {
|
||||
const key = customerNameKey(row.customerName);
|
||||
const prev = sentByKey.get(key) ?? { count: 0, last: null };
|
||||
const last = row._max.sentAt;
|
||||
sentByKey.set(key, {
|
||||
count: prev.count + row._count._all,
|
||||
last: !prev.last || (last && last > prev.last) ? last : prev.last,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const candidates: Candidate[] = [];
|
||||
for (const snap of snapshots) {
|
||||
if (snap.totalInCents <= 0) continue;
|
||||
if (sentTabIds.has(snap.tabId)) continue;
|
||||
const email = emailByKey.get(customerNameKey(snap.customerName));
|
||||
if (!email) continue;
|
||||
const key = customerNameKey(snap.customerName);
|
||||
const email = emailByKey.get(key) ?? null;
|
||||
const sent = sentByKey.get(key);
|
||||
const nextEligibleAt = sent?.last ? new Date(sent.last.getTime() + COOLDOWN_MS) : null;
|
||||
candidates.push({
|
||||
tabId: snap.tabId,
|
||||
customerName: snap.customerName,
|
||||
@@ -74,13 +99,21 @@ export class RemindersService {
|
||||
itemCount: snap.itemCount,
|
||||
// Null when the terminal's app version doesn't report lines
|
||||
items: (snap.items as TabLine[] | null) ?? null,
|
||||
reminderCount: sent?.count ?? 0,
|
||||
lastReminderAt: sent?.last ?? null,
|
||||
nextEligibleAt,
|
||||
eligible: email !== null && (!nextEligibleAt || nextEligibleAt.getTime() <= now),
|
||||
});
|
||||
}
|
||||
|
||||
return { candidates };
|
||||
return { candidates, cooldownDays: REMINDER_COOLDOWN_DAYS };
|
||||
}
|
||||
|
||||
async sendReminders(venueId: string) {
|
||||
/**
|
||||
* Sends to the given tabs, eligible or not (an admin's explicit choice overrides the cooldown),
|
||||
* or to every eligible customer when no tabs are given.
|
||||
*/
|
||||
async sendReminders(venueId: string, tabIds?: string[]) {
|
||||
if (!this.resendApiKey) {
|
||||
throw new BadRequestException({
|
||||
code: 'resend_not_configured',
|
||||
@@ -95,14 +128,18 @@ export class RemindersService {
|
||||
this.sendingVenues.add(venueId);
|
||||
|
||||
try {
|
||||
return await this.sendToCandidates(venueId);
|
||||
return await this.sendToCandidates(venueId, tabIds);
|
||||
} finally {
|
||||
this.sendingVenues.delete(venueId);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendToCandidates(venueId: string) {
|
||||
const { candidates } = await this.listCandidates(venueId);
|
||||
private async sendToCandidates(venueId: string, tabIds?: string[]) {
|
||||
const { candidates: all } = await this.listCandidates(venueId);
|
||||
const selected = tabIds ? new Set(tabIds) : null;
|
||||
const candidates = all.filter((c): c is Recipient =>
|
||||
c.email !== null && (selected ? selected.has(c.tabId) : c.eligible),
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { sent: 0, total: 0 };
|
||||
@@ -112,7 +149,7 @@ export class RemindersService {
|
||||
let sent = 0;
|
||||
const errors: { customerName: string; error: string }[] = [];
|
||||
|
||||
const rendered: { candidate: Candidate; email: RenderedEmail }[] = [];
|
||||
const rendered: { candidate: Recipient; email: RenderedEmail }[] = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) });
|
||||
@@ -123,7 +160,11 @@ export class RemindersService {
|
||||
|
||||
for (let i = 0; i < rendered.length; i += BATCH_SIZE) {
|
||||
const batch = rendered.slice(i, i + BATCH_SIZE);
|
||||
const error = await this.sendBatch(batch.map(({ candidate, email }) => ({ to: candidate.email, email })));
|
||||
const error = await this.sendBatch(batch.map(({ candidate, email }) => ({
|
||||
to: candidate.email,
|
||||
email,
|
||||
reminderCount: candidate.reminderCount,
|
||||
})));
|
||||
if (error) {
|
||||
for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error });
|
||||
continue;
|
||||
@@ -146,9 +187,12 @@ export class RemindersService {
|
||||
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
|
||||
}
|
||||
|
||||
async history(venueId: string) {
|
||||
async history(venueId: string, customerName?: string) {
|
||||
const reminders = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId },
|
||||
where: {
|
||||
venueId,
|
||||
...(customerName ? { customerName: { equals: customerName.trim(), mode: 'insensitive' as const } } : {}),
|
||||
},
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
@@ -200,7 +244,7 @@ export class RemindersService {
|
||||
return { ok: true, email: tab.email, customerName: tab.customerName };
|
||||
}
|
||||
|
||||
private renderReminder(candidate: Candidate, venueName: string): Promise<RenderedEmail> {
|
||||
private renderReminder(candidate: Recipient, venueName: string): Promise<RenderedEmail> {
|
||||
return this.emailTemplates.render('payment-reminder', {
|
||||
customerName: candidate.customerName,
|
||||
venueName,
|
||||
@@ -217,10 +261,12 @@ export class RemindersService {
|
||||
}
|
||||
|
||||
/** One Resend batch request; returns Resend's error text, or null when sent. */
|
||||
private sendBatch(messages: { to: string; email: RenderedEmail }[]): Promise<string | null> {
|
||||
private sendBatch(messages: { to: string; email: RenderedEmail; reminderCount: number }[]): Promise<string | null> {
|
||||
const body = messages.map(({ to, email }) => ({ from: this.fromEmail, to, ...email }));
|
||||
// Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice
|
||||
const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
||||
// Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice.
|
||||
// The reminder counts are part of the key: a deliberate repeat after a successful send is a new request.
|
||||
const counts = messages.map((m) => m.reminderCount);
|
||||
const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify([body, counts])).digest('hex');
|
||||
return this.resend('/emails/batch', body, idempotencyKey);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user