Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastDigestSentAt" DATETIME;
1 change: 1 addition & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ model User {
targetRole String?
emailVerifiedAt DateTime?
weeklyDigestEnabled Boolean @default(true)
lastDigestSentAt DateTime?
followUpRemindersEnabled Boolean @default(true)
totpSecret String?
totpEnabled Boolean @default(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('BulkDeleteApplicationsUseCase', () => {
});
});

it('propagates a per-item error (e.g. NOT_FOUND for a stale id)', async () => {
it('treats a NOT_FOUND item as an idempotent no-op (retried bulk-delete)', async () => {
const deleteApplicationUseCase = stub<IDeleteApplicationUseCase>({
execute: vi.fn().mockImplementation(({ applicationId }: { applicationId: string }) => {
if (applicationId === 'app-2') {
Expand All @@ -56,10 +56,26 @@ describe('BulkDeleteApplicationsUseCase', () => {
});
const useCase = new BulkDeleteApplicationsUseCase({ deleteApplicationUseCase });

await expect(
useCase.execute({ userId: 'user-1', applicationIds: ['app-1', 'app-2'] }),
).resolves.toBeUndefined();
});

it('still throws when an item fails for a real reason (e.g. FORBIDDEN)', async () => {
const deleteApplicationUseCase = stub<IDeleteApplicationUseCase>({
execute: vi.fn().mockImplementation(({ applicationId }: { applicationId: string }) => {
if (applicationId === 'app-2') {
return Promise.reject(Object.assign(new Error('Forbidden'), { code: 'FORBIDDEN' }));
}
return Promise.resolve(undefined);
}),
});
const useCase = new BulkDeleteApplicationsUseCase({ deleteApplicationUseCase });

const err = await useCase
.execute({ userId: 'user-1', applicationIds: ['app-1', 'app-2'] })
.catch((e) => e);

expect((err as { code: string }).code).toBe('NOT_FOUND');
expect((err as { code: string }).code).toBe('FORBIDDEN');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
makeApplicationRepository,
makeApplication,
makeTransactionManager,
makeActivityLogRepository,
} from '@/__tests__/helpers/mocks.js';

describe('UpdateApplicationUseCase', () => {
Expand Down Expand Up @@ -133,4 +134,117 @@ describe('UpdateApplicationUseCase', () => {
useCase.execute({ userId: 'user-1', applicationId: 'app-1', company: 'Acme' }),
).resolves.toBeDefined();
});

it('does not log field_updated when the submitted value equals the current value', async () => {
const existing = makeApplication({ company: 'Acme' });
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(existing),
});
const activityLogRepository = makeActivityLogRepository();

const useCase = new UpdateApplicationUseCase({
applicationRepository,
activityLogRepository,
generateId: () => 'log-1',
});
await useCase.execute({ userId: 'user-1', applicationId: 'app-1', company: 'Acme' });

expect(activityLogRepository.append).not.toHaveBeenCalled();
});

it('logs field_updated only for fields that actually changed', async () => {
const existing = makeApplication({ company: 'Acme', role: 'Engineer' });
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(makeApplication({ company: 'Acme', role: 'Staff Eng' })),
});
const activityLogRepository = makeActivityLogRepository();

const useCase = new UpdateApplicationUseCase({
applicationRepository,
activityLogRepository,
generateId: () => 'log-1',
});
await useCase.execute({
userId: 'user-1',
applicationId: 'app-1',
company: 'Acme',
role: 'Staff Eng',
});

expect(activityLogRepository.append).toHaveBeenCalledOnce();
expect(activityLogRepository.append).toHaveBeenCalledWith(
expect.objectContaining({ payload: JSON.stringify({ fields: ['role'] }) }),
);
});

it('treats an identical followUpAt instant as unchanged even with a new Date object', async () => {
const existing = makeApplication({ followUpAt: new Date('2026-08-01T00:00:00.000Z') });
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(existing),
});
const activityLogRepository = makeActivityLogRepository();

const useCase = new UpdateApplicationUseCase({
applicationRepository,
activityLogRepository,
generateId: () => 'log-1',
});
await useCase.execute({
userId: 'user-1',
applicationId: 'app-1',
followUpAt: new Date('2026-08-01T00:00:00.000Z'),
});

expect(activityLogRepository.append).not.toHaveBeenCalled();
});

it('logs field_updated when followUpAt actually changes', async () => {
const existing = makeApplication({ followUpAt: new Date('2026-08-01T00:00:00.000Z') });
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(existing),
});
const activityLogRepository = makeActivityLogRepository();

const useCase = new UpdateApplicationUseCase({
applicationRepository,
activityLogRepository,
generateId: () => 'log-1',
});
await useCase.execute({
userId: 'user-1',
applicationId: 'app-1',
followUpAt: new Date('2026-08-15T00:00:00.000Z'),
});

expect(activityLogRepository.append).toHaveBeenCalledWith(
expect.objectContaining({ payload: JSON.stringify({ fields: ['followUpAt'] }) }),
);
});

it('still logs status_changed as before (regression guard)', async () => {
const existing = makeApplication({ status: 'draft' });
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(makeApplication({ status: 'applied' })),
});
const activityLogRepository = makeActivityLogRepository();

const useCase = new UpdateApplicationUseCase({
applicationRepository,
activityLogRepository,
generateId: () => 'log-1',
});
await useCase.execute({ userId: 'user-1', applicationId: 'app-1', status: 'applied' });

expect(activityLogRepository.append).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'status_changed',
payload: JSON.stringify({ from: 'draft', to: 'applied' }),
}),
);
});
});
41 changes: 41 additions & 0 deletions apps/api/src/__tests__/digest/SendWeeklyDigestUseCase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,47 @@ describe('SendWeeklyDigestUseCase', () => {
expect(result).toEqual({ totalUsers: 2, sent: 1, skipped: 1 });
expect(emailService.sendWeeklyDigest).toHaveBeenCalledOnce();
expect(emailService.sendWeeklyDigest).toHaveBeenCalledWith('a@test.com', expect.any(Object));
expect(userRepository.updateLastDigestSentAt).toHaveBeenCalledOnce();
expect(userRepository.updateLastDigestSentAt).toHaveBeenCalledWith('u1', expect.any(Date));
});

it('skips a user whose lastDigestSentAt is within the resend window', async () => {
const user = makeUser({ lastDigestSentAt: new Date(Date.now() - 1 * DAY_MS) });
const userRepository = makeUserRepository({ findAll: vi.fn().mockResolvedValue([user]) });
const applicationRepository = makeApplicationRepository({
findAllByUserId: vi.fn().mockResolvedValue([makeApplication()]),
});
const emailService = makeEmailService();

const result = await new SendWeeklyDigestUseCase({
userRepository,
applicationRepository,
emailService,
}).execute();

expect(result).toEqual({ totalUsers: 1, sent: 0, skipped: 1 });
expect(applicationRepository.findAllByUserId).not.toHaveBeenCalled();
expect(emailService.sendWeeklyDigest).not.toHaveBeenCalled();
expect(userRepository.updateLastDigestSentAt).not.toHaveBeenCalled();
});

it('sends again once the resend window has elapsed', async () => {
const user = makeUser({ lastDigestSentAt: new Date(Date.now() - 7 * DAY_MS) });
const userRepository = makeUserRepository({ findAll: vi.fn().mockResolvedValue([user]) });
const applicationRepository = makeApplicationRepository({
findAllByUserId: vi.fn().mockResolvedValue([makeApplication()]),
});
const emailService = makeEmailService();

const result = await new SendWeeklyDigestUseCase({
userRepository,
applicationRepository,
emailService,
}).execute();

expect(result).toEqual({ totalUsers: 1, sent: 1, skipped: 0 });
expect(emailService.sendWeeklyDigest).toHaveBeenCalledOnce();
expect(userRepository.updateLastDigestSentAt).toHaveBeenCalledWith(user.id, expect.any(Date));
});

it('categorises new applications created in the last 7 days', async () => {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/__tests__/helpers/createTestDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const SCHEMA_STATEMENTS = [
"targetRole" TEXT,
"emailVerifiedAt" DATETIME,
"weeklyDigestEnabled" INTEGER NOT NULL DEFAULT 1,
"lastDigestSentAt" DATETIME,
"followUpRemindersEnabled" INTEGER NOT NULL DEFAULT 1,
"totpSecret" TEXT,
"totpEnabled" INTEGER NOT NULL DEFAULT 0,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/__tests__/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const makeUserRepository = (overrides?: Partial<IUserRepository>): IUserR
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
updateLastDigestSentAt: vi.fn().mockResolvedValue(undefined),
...overrides,
});

Expand Down Expand Up @@ -268,6 +269,7 @@ export const makeUser = (overrides?: Partial<User>): User => ({
targetRole: null,
emailVerifiedAt: null,
weeklyDigestEnabled: true,
lastDigestSentAt: null,
followUpRemindersEnabled: true,
totpSecret: null,
totpEnabled: false,
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ export const REMINDER_WINDOW_MS = {
RESEND_AFTER: 23 * 60 * 60 * 1000, // don't resend within 23h
} as const;

/** Weekly-digest resend-guard window, in milliseconds. */
export const DIGEST_WINDOW_MS = {
/** Don't resend the digest if the last send was within this window (digest cadence is 7 days). */
RESEND_AFTER: 6 * 24 * 60 * 60 * 1000, // 6 days
} as const;

/** Default field values applied when the caller omits them. */
export const DEFAULTS = {
APPLICATION_STATUS: 'draft',
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/domain/user/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface User {
targetRole: string | null;
emailVerifiedAt: Date | null;
weeklyDigestEnabled: boolean;
lastDigestSentAt: Date | null;
followUpRemindersEnabled: boolean;
totpSecret: string | null;
totpEnabled: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type PrismaUser = {
targetRole: string | null;
emailVerifiedAt: Date | null;
weeklyDigestEnabled: boolean;
lastDigestSentAt: Date | null;
followUpRemindersEnabled: boolean;
totpSecret: string | null;
totpEnabled: boolean;
Expand Down Expand Up @@ -73,6 +74,10 @@ export class PrismaUserRepository implements IUserRepository {
await this.db.user.delete({ where: { id } });
}

async updateLastDigestSentAt(id: string, sentAt: Date): Promise<void> {
await this.db.user.update({ where: { id }, data: { lastDigestSentAt: sentAt } });
}

private toEntity(row: PrismaUser): User {
return {
id: row.id,
Expand All @@ -83,6 +88,7 @@ export class PrismaUserRepository implements IUserRepository {
targetRole: row.targetRole,
emailVerifiedAt: row.emailVerifiedAt,
weeklyDigestEnabled: row.weeklyDigestEnabled,
lastDigestSentAt: row.lastDigestSentAt,
followUpRemindersEnabled: row.followUpRemindersEnabled,
totpSecret: row.totpSecret,
totpEnabled: row.totpEnabled,
Expand Down
14 changes: 12 additions & 2 deletions apps/api/src/use-cases/digest/SendWeeklyDigestUseCase.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js';
import type { IApplicationRepository } from '@/use-cases/ports/IApplicationRepository.js';
import type { IEmailService, WeeklyDigestData } from '@/use-cases/ports/IEmailService.js';
import { DURATIONS_MS } from '@/constants.js';
import { DURATIONS_MS, DIGEST_WINDOW_MS } from '@/constants.js';

interface Deps {
userRepository: IUserRepository;
Expand Down Expand Up @@ -32,13 +32,22 @@ export class SendWeeklyDigestUseCase {
return;
}

const now = new Date();

if (
user.lastDigestSentAt &&
user.lastDigestSentAt.getTime() > now.getTime() - DIGEST_WINDOW_MS.RESEND_AFTER
) {
skipped++;
return;
}

const apps = await this.deps.applicationRepository.findAllByUserId(user.id);
if (apps.length === 0) {
skipped++;
return;
}

const now = new Date();
const weekAgo = new Date(now.getTime() - SEVEN_DAYS_MS);
const nextWeek = new Date(now.getTime() + SEVEN_DAYS_MS);

Expand Down Expand Up @@ -73,6 +82,7 @@ export class SendWeeklyDigestUseCase {
};

await this.deps.emailService.sendWeeklyDigest(user.email, data);
await this.deps.userRepository.updateLastDigestSentAt(user.id, now);
sent++;
}),
);
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/use-cases/jobs/BulkDeleteApplicationsUseCase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IDeleteApplicationUseCase } from '@/use-cases/jobs/IDeleteApplicationUseCase.js';
import { assertValidBulkIds } from '@/use-cases/jobs/bulkValidation.js';
import { ERROR_CODES } from '@/constants.js';
import type {
IBulkDeleteApplicationsUseCase,
BulkDeleteApplicationsInput,
Expand All @@ -15,10 +16,21 @@ export class BulkDeleteApplicationsUseCase implements IBulkDeleteApplicationsUse
async execute(input: BulkDeleteApplicationsInput): Promise<void> {
assertValidBulkIds(input.applicationIds);

await Promise.all(
const results = await Promise.allSettled(
input.applicationIds.map((applicationId) =>
this.deps.deleteApplicationUseCase.execute({ userId: input.userId, applicationId }),
),
);

// A NOT_FOUND item is already gone (e.g. a retried bulk-delete after a
// partial success) — treat it as an idempotent no-op, not a failure.
const realFailure = results.find(
(r): r is PromiseRejectedResult =>
r.status === 'rejected' && (r.reason as { code?: string })?.code !== ERROR_CODES.NOT_FOUND,
);

if (realFailure) {
throw realFailure.reason;
}
}
}
Loading
Loading