fix: auth + double sending etc
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-30 00:41:58 +01:00
parent 314717ba3a
commit 3caa6d6f0d
15 changed files with 129 additions and 32 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ export class AdminController {
@Post('images/cleanup')
@HttpCode(HttpStatus.OK)
cleanupImages(@Body(zod(dryRunSchema)) dto: Body<typeof dryRunSchema>, @CurrentUser() user: AuthUser) {
return this.admin.cleanupImages(dto.dryRun, user);
return this.admin.cleanupImages(dto.dryRun, { type: 'user', id: user.id });
}
@Post('email/test')
+2 -1
View File
@@ -4,10 +4,11 @@ import { RemindersModule } from '../reminders/reminders.module';
import { VenuesModule } from '../venues/venues.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { HousekeepingService } from './housekeeping.service';
@Module({
imports: [ImagesModule, RemindersModule, VenuesModule],
controllers: [AdminController],
providers: [AdminService],
providers: [AdminService, HousekeepingService],
})
export class AdminModule {}
+12 -3
View File
@@ -7,7 +7,7 @@ import {
SystemHealth,
} from '@kooltap/shared';
import { Prisma, PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditEntry as AuditRecord, AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { RemindersService } from '../reminders/reminders.service';
import { VenuesService } from '../venues/venues.service';
@@ -38,6 +38,11 @@ const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
/** Dashboard sessions that expired or were signed out before [before]; they can never be used again. */
export const endedSessions = (before: Date): Prisma.RefreshTokenWhereInput => ({
OR: [{ expiresAt: { lt: before } }, { revokedAt: { lt: before } }],
});
/** Everything the superadmin page does. */
@Injectable()
export class AdminService {
@@ -387,9 +392,13 @@ export class AdminService {
const venue = venueId ? { venueId } : {};
const tabEvents = { ...venue, receivedAt: { lt: before } };
const auditLog = { ...venue, createdAt: { lt: before } };
const refreshTokens = { ...endedSessions(before), ...(venueId ? { user: { venueId } } : {}) };
const run: Record<PurgeTarget, () => Promise<number>> = {
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: tabEvents }) : this.prisma.tabEvent.deleteMany({ where: tabEvents }).then((r) => r.count)),
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: auditLog }) : this.prisma.auditLog.deleteMany({ where: auditLog }).then((r) => r.count)),
refreshTokens: () => (dryRun
? this.prisma.refreshToken.count({ where: refreshTokens })
: this.prisma.refreshToken.deleteMany({ where: refreshTokens }).then((r) => r.count)),
};
const counts: Partial<Record<PurgeTarget, number>> = {};
@@ -401,7 +410,7 @@ export class AdminService {
}
/** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */
async cleanupImages(dryRun: boolean, actor: AuthUser) {
async cleanupImages(dryRun: boolean, actor: AuditRecord['actor']) {
const images = await this.prisma.productImage.findMany({
where: { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } },
select: { id: true, bytes: true },
@@ -409,7 +418,7 @@ export class AdminService {
const bytes = images.reduce((sum, image) => sum + image.bytes, 0);
if (!dryRun && images.length > 0) {
await this.images.remove(images.map((image) => image.id));
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_images_cleaned', details: { count: images.length } });
await this.audit.log({ venueId: null, actor, action: 'admin_images_cleaned', details: { count: images.length } });
}
return { dryRun, count: images.length, bytes };
}
@@ -0,0 +1,53 @@
import { Injectable, Logger, OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AdminService, endedSessions } from './admin.service';
const DAY_MS = 24 * 60 * 60 * 1000;
// Out of the way of startup and the migrations, then once a day
const FIRST_RUN_DELAY_MS = 5 * 60 * 1000;
const INTERVAL_MS = DAY_MS;
// Ended sessions are kept a while, so a superadmin can still see recent sign-outs while looking into something
const ENDED_SESSION_RETENTION_MS = 7 * DAY_MS;
/**
* Daily cleanup of what can never be used again: ended sessions, used and expired registration codes, and pictures
* no product uses. History (terminal events, audit log) is a choice, so it stays with the purge on the admin page.
* Every instance runs it; the deletes don't mind running twice.
*/
@Injectable()
export class HousekeepingService implements OnApplicationBootstrap, OnApplicationShutdown {
private readonly logger = new Logger(HousekeepingService.name);
private timer: NodeJS.Timeout | null = null;
constructor(
private prisma: PrismaService,
private admin: AdminService,
) {}
onApplicationBootstrap() {
// Tests run against a database they set up themselves
if (process.env.NODE_ENV === 'test') return;
this.timer = setTimeout(() => {
void this.run();
this.timer = setInterval(() => void this.run(), INTERVAL_MS).unref();
}, FIRST_RUN_DELAY_MS).unref();
}
onApplicationShutdown() {
if (this.timer) clearTimeout(this.timer);
}
/** Never throws: a failure is logged and the next run tries again. */
async run() {
try {
const sessions = await this.prisma.refreshToken.deleteMany({ where: endedSessions(new Date(Date.now() - ENDED_SESSION_RETENTION_MS)) });
const codes = await this.admin.cleanupRegistrationCodes();
const images = await this.admin.cleanupImages(false, { type: 'system', id: null });
this.logger.log(`Deleted ${sessions.count} ended session(s), ${codes.deleted} registration code(s), ${images.count} unused picture(s)`);
} catch (error) {
this.logger.error(`Housekeeping failed: ${error instanceof Error ? error.stack : error}`);
}
}
}