feat: better reminder tab
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 10:39:06 +02:00
parent 874f68b4b1
commit fb6d26a468
8 changed files with 441 additions and 86 deletions
@@ -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[];
}
+10 -8
View File
@@ -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']);
});
});
+71 -25
View File
@@ -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);
}