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
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ R2_SECRET_ACCESS_KEY=

# CORS
CORS_ORIGIN=http://localhost:3000

# Brevo email (for follow-up reminders)
BREVO_API_KEY=
FROM_EMAIL=noreply@yourdomain.com
FROM_NAME=Job Finder
Comment on lines +26 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Quote environment variables that contain spaces.

As flagged by the static analysis tools, variables containing spaces should be enclosed in quotes to ensure compatibility across different environment variable parsers and deployment platforms.

🛠 Proposed fix
 FROM_EMAIL=noreply@yourdomain.com
-FROM_NAME=Job Finder
+FROM_NAME="Job Finder"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
FROM_EMAIL=noreply@yourdomain.com
FROM_NAME=Job Finder
FROM_EMAIL=noreply@yourdomain.com
FROM_NAME="Job Finder"
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 27-27: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/.env.example` around lines 26 - 27, Update the FROM_NAME environment
variable in the example configuration to quote its space-containing value, while
leaving FROM_EMAIL unchanged.

Source: Linters/SAST tools

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "JobApplication" ADD COLUMN "reminderSentAt" DATETIME;
7 changes: 4 additions & 3 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ model JobApplication {
appliedAt DateTime?
starred Boolean @default(false)
source String?
followUpAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
followUpAt DateTime?
reminderSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
interviewRounds InterviewRound[]
notes Note[]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from 'vitest';
import { SendFollowUpRemindersUseCase } from '@/use-cases/reminders/SendFollowUpRemindersUseCase.js';
import { makeApplicationRepository, makeUserRepository, makeApplication, makeUser } from '@/__tests__/helpers/mocks.js';
import type { IEmailService } from '@/use-cases/ports/IEmailService.js';

const makeEmailService = (overrides?: Partial<IEmailService>): IEmailService => ({
sendFollowUpReminder: vi.fn().mockResolvedValue(undefined),
...overrides,
});

describe('SendFollowUpRemindersUseCase', () => {
it('sends email and updates reminderSentAt for due applications', async () => {
const followUpAt = new Date(Date.now() + 12 * 60 * 60 * 1000);
const app = makeApplication({ followUpAt });
const user = makeUser();
const applicationRepository = makeApplicationRepository({
findDueForReminder: vi.fn().mockResolvedValue([app]),
updateReminderSentAt: vi.fn().mockResolvedValue(undefined),
});
const userRepository = makeUserRepository({
findById: vi.fn().mockResolvedValue(user),
});
const emailService = makeEmailService();

const useCase = new SendFollowUpRemindersUseCase({
applicationRepository,
userRepository,
emailService,
});
await useCase.execute();

expect(emailService.sendFollowUpReminder).toHaveBeenCalledWith(
user.email,
app.company,
app.role,
followUpAt,
);
expect(applicationRepository.updateReminderSentAt).toHaveBeenCalledWith(
app.id,
expect.any(Date),
);
});

it('skips applications when user is not found', async () => {
const app = makeApplication({ followUpAt: new Date() });
const applicationRepository = makeApplicationRepository({
findDueForReminder: vi.fn().mockResolvedValue([app]),
updateReminderSentAt: vi.fn(),
});
const userRepository = makeUserRepository({
findById: vi.fn().mockResolvedValue(null),
});
const emailService = makeEmailService();

await new SendFollowUpRemindersUseCase({ applicationRepository, userRepository, emailService }).execute();

expect(emailService.sendFollowUpReminder).not.toHaveBeenCalled();
expect(applicationRepository.updateReminderSentAt).not.toHaveBeenCalled();
});

it('continues past individual email failures without throwing', async () => {
const apps = [
makeApplication({ id: 'app-1', followUpAt: new Date() }),
makeApplication({ id: 'app-2', followUpAt: new Date() }),
];
const user = makeUser();
const applicationRepository = makeApplicationRepository({
findDueForReminder: vi.fn().mockResolvedValue(apps),
updateReminderSentAt: vi.fn().mockResolvedValue(undefined),
});
const userRepository = makeUserRepository({
findById: vi.fn().mockResolvedValue(user),
});
const emailService = makeEmailService({
sendFollowUpReminder: vi.fn()
.mockRejectedValueOnce(new Error('Brevo timeout'))
.mockResolvedValueOnce(undefined),
});

await expect(
new SendFollowUpRemindersUseCase({ applicationRepository, userRepository, emailService }).execute(),
).resolves.not.toThrow();

expect(emailService.sendFollowUpReminder).toHaveBeenCalledTimes(2);
// Only the second app (which succeeded) should have reminderSentAt updated
expect(applicationRepository.updateReminderSentAt).toHaveBeenCalledTimes(1);
expect(applicationRepository.updateReminderSentAt).toHaveBeenCalledWith('app-2', expect.any(Date));
});
});
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 @@ -36,6 +36,7 @@ const SCHEMA_STATEMENTS = [
"starred" INTEGER NOT NULL DEFAULT 0,
"source" TEXT,
"followUpAt" DATETIME,
"reminderSentAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/__tests__/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const makeApplicationRepository = (
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findDueForReminder: vi.fn().mockResolvedValue([]),
updateReminderSentAt: vi.fn().mockResolvedValue(undefined),
...overrides,
});

Expand Down Expand Up @@ -136,6 +138,7 @@ export const makeApplication = (overrides?: Partial<Application>): Application =
source: null,
followUpAt: null,
tags: [],
reminderSentAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import authPlugin from '@/http/plugins/auth.plugin.js';
import corsPlugin from '@/http/plugins/cors.plugin.js';
import remindersPlugin from '@/http/plugins/reminders.plugin.js';
import { buildContainer } from '@/http/container.js';
import { schema } from '@/http/schema/index.js';
import { formatError } from '@/http/errors/formatError.js';
Expand All @@ -24,6 +25,8 @@

buildContainer(fastify);

await fastify.register(remindersPlugin);

await fastify.register(mercurius, {
schema,
graphiql: process.env.NODE_ENV !== 'production',
Expand All @@ -43,7 +46,7 @@
if (rawToken.startsWith('jfat_')) {
// API token path — hash and look up in DB
try {
const { validateApiTokenUseCase } = (request as any).diScope.cradle;

Check warning on line 49 in apps/api/src/app.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
user = await validateApiTokenUseCase.execute(rawToken);
} catch {
// Invalid API token — unauthenticated
Expand All @@ -60,7 +63,7 @@

return {
user,
diScope: (request as any).diScope,

Check warning on line 66 in apps/api/src/app.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
request,
reply,
};
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/domain/application/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface Application {
source: string | null;
followUpAt: Date | null;
tags: string[];
reminderSentAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
6 changes: 6 additions & 0 deletions apps/api/src/http/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ import { CreateContactUseCase } from '@/use-cases/contacts/CreateContactUseCase.
import { GetContactsUseCase } from '@/use-cases/contacts/GetContactsUseCase.js';
import { UpdateContactUseCase } from '@/use-cases/contacts/UpdateContactUseCase.js';
import { DeleteContactUseCase } from '@/use-cases/contacts/DeleteContactUseCase.js';
import { BrevoEmailService } from '@/infrastructure/email/BrevoEmailService.js';
import { SendFollowUpRemindersUseCase } from '@/use-cases/reminders/SendFollowUpRemindersUseCase.js';

import type { FastifyInstance } from 'fastify';

Expand Down Expand Up @@ -149,6 +151,8 @@ declare module '@fastify/awilix' {
getContactsUseCase: GetContactsUseCase;
updateContactUseCase: UpdateContactUseCase;
deleteContactUseCase: DeleteContactUseCase;
emailService: BrevoEmailService;
sendFollowUpRemindersUseCase: SendFollowUpRemindersUseCase;
}
}

Expand Down Expand Up @@ -242,5 +246,7 @@ export function buildContainer(fastify: FastifyInstance): void {
getContactsUseCase: asClass(GetContactsUseCase, { lifetime: Lifetime.TRANSIENT }),
updateContactUseCase: asClass(UpdateContactUseCase, { lifetime: Lifetime.TRANSIENT }),
deleteContactUseCase: asClass(DeleteContactUseCase, { lifetime: Lifetime.TRANSIENT }),
emailService: asClass(BrevoEmailService, { lifetime: Lifetime.SINGLETON }),
sendFollowUpRemindersUseCase: asClass(SendFollowUpRemindersUseCase, { lifetime: Lifetime.TRANSIENT }),
});
}
18 changes: 18 additions & 0 deletions apps/api/src/http/plugins/reminders.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import fp from 'fastify-plugin';
import type { FastifyInstance } from 'fastify';

export default fp(async (fastify: FastifyInstance) => {
const INTERVAL_MS = 60 * 60 * 1000; // 1 hour

const run = async () => {
try {
const { sendFollowUpRemindersUseCase } = fastify.diContainer.cradle;
await sendFollowUpRemindersUseCase.execute();
} catch {
// swallow — don't crash the server
}
};
Comment on lines +7 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Log errors instead of silently swallowing them.

Silently catching and ignoring errors will make it very difficult to debug if the background reminder process starts failing (e.g., due to database connectivity issues or email service misconfigurations). Consider logging the error using Fastify's built-in logger.

💻 Proposed fix
   const run = async () => {
     try {
       const { sendFollowUpRemindersUseCase } = fastify.diContainer.cradle;
       await sendFollowUpRemindersUseCase.execute();
-    } catch {
-      // swallow — don't crash the server
+    } catch (error) {
+      fastify.log.error(error, 'Failed to execute follow-up reminders process');
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const run = async () => {
try {
const { sendFollowUpRemindersUseCase } = fastify.diContainer.cradle;
await sendFollowUpRemindersUseCase.execute();
} catch {
// swallow — don't crash the server
}
};
const run = async () => {
try {
const { sendFollowUpRemindersUseCase } = fastify.diContainer.cradle;
await sendFollowUpRemindersUseCase.execute();
} catch (error) {
fastify.log.error(error, 'Failed to execute follow-up reminders process');
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/http/plugins/reminders.plugin.ts` around lines 7 - 14, Update
the catch block in the run function to log the caught error through Fastify’s
built-in logger, while preserving the existing behavior of preventing background
reminder failures from crashing the server.


const timer = setInterval(run, INTERVAL_MS);
fastify.addHook('onClose', () => { clearInterval(timer); });
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,13 @@ export class CachedApplicationRepository implements IApplicationRepository {
this.userIdByAppId.delete(id);
if (userId) this.cache.deleteByPrefix(`apps:list:${userId}:`);
}

async findDueForReminder(): Promise<Application[]> {
return this.inner.findDueForReminder();
}

async updateReminderSentAt(id: string, sentAt: Date): Promise<void> {
await this.inner.updateReminderSentAt(id, sentAt);
this.cache.delete(`apps:byId:${id}`);
}
Comment on lines +73 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Invalidate the list cache to prevent stale application data.

When a reminder is recorded, the Application object is modified. Failing to invalidate the list cache (apps:list:${userId}:) will result in users seeing stale reminder statuses when viewing their applications.

To enable updateReminderSentAt to clear the correct list cache, findDueForReminder must also populate the userIdByAppId map.

♻️ Proposed fix to keep the cache consistent
-  async findDueForReminder(): Promise<Application[]> {
-    return this.inner.findDueForReminder();
-  }
-
-  async updateReminderSentAt(id: string, sentAt: Date): Promise<void> {
-    await this.inner.updateReminderSentAt(id, sentAt);
-    this.cache.delete(`apps:byId:${id}`);
-  }
+  async findDueForReminder(): Promise<Application[]> {
+    const result = await this.inner.findDueForReminder();
+    for (const app of result) {
+      this.userIdByAppId.set(app.id, app.userId);
+    }
+    return result;
+  }
+
+  async updateReminderSentAt(id: string, sentAt: Date): Promise<void> {
+    await this.inner.updateReminderSentAt(id, sentAt);
+    this.cache.delete(`apps:byId:${id}`);
+    const userId = this.userIdByAppId.get(id);
+    if (userId) {
+      this.cache.deleteByPrefix(`apps:list:${userId}:`);
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async findDueForReminder(): Promise<Application[]> {
return this.inner.findDueForReminder();
}
async updateReminderSentAt(id: string, sentAt: Date): Promise<void> {
await this.inner.updateReminderSentAt(id, sentAt);
this.cache.delete(`apps:byId:${id}`);
}
async findDueForReminder(): Promise<Application[]> {
const result = await this.inner.findDueForReminder();
for (const app of result) {
this.userIdByAppId.set(app.id, app.userId);
}
return result;
}
async updateReminderSentAt(id: string, sentAt: Date): Promise<void> {
await this.inner.updateReminderSentAt(id, sentAt);
this.cache.delete(`apps:byId:${id}`);
const userId = this.userIdByAppId.get(id);
if (userId) {
this.cache.deleteByPrefix(`apps:list:${userId}:`);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/db/repositories/CachedApplicationRepository.ts`
around lines 73 - 81, Update CachedApplicationRepository.findDueForReminder to
populate the userIdByAppId mapping for every returned application, then update
updateReminderSentAt to use that mapping to invalidate the corresponding
apps:list:${userId}: cache entry in addition to the existing by-id cache
deletion; remove or safely handle the mapping entry after invalidation as
appropriate.

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type PrismaApp = {
starred: boolean;
source: string | null;
followUpAt: Date | null;
reminderSentAt: Date | null;
createdAt: Date;
updatedAt: Date;
tags: PrismaTag[];
Expand Down Expand Up @@ -127,6 +128,26 @@ export class PrismaApplicationRepository implements IApplicationRepository {
await this.db.jobApplication.delete({ where: { id } });
}

async findDueForReminder(): Promise<Application[]> {
const now = new Date();
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const rows = await this.db.jobApplication.findMany({
where: {
followUpAt: { gte: now, lte: in24h },
OR: [
{ reminderSentAt: null },
{ reminderSentAt: { lte: new Date(now.getTime() - 23 * 60 * 60 * 1000) } },
],
},
include: INCLUDE_TAGS,
});
return rows.map(this.toEntity);
}

async updateReminderSentAt(id: string, sentAt: Date): Promise<void> {
await this.db.jobApplication.update({ where: { id }, data: { reminderSentAt: sentAt } });
}

private toEntity(row: PrismaApp): Application {
return {
id: row.id,
Expand All @@ -142,7 +163,8 @@ export class PrismaApplicationRepository implements IApplicationRepository {
starred: row.starred,
source: row.source,
followUpAt: row.followUpAt,
tags: row.tags.map((t) => t.name),
tags: row.tags?.map((t: { name: string }) => t.name) ?? [],
reminderSentAt: row.reminderSentAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/infrastructure/email/BrevoEmailService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { IEmailService } from '@/use-cases/ports/IEmailService.js';

export class BrevoEmailService implements IEmailService {
private readonly apiKey: string;
private readonly fromEmail: string;
private readonly fromName: string;

constructor() {
this.apiKey = process.env.BREVO_API_KEY ?? '';
this.fromEmail = process.env.FROM_EMAIL ?? 'noreply@jobfinder.app';
this.fromName = process.env.FROM_NAME ?? 'Job Finder';
}

async sendFollowUpReminder(
to: string,
company: string,
role: string,
followUpAt: Date,
): Promise<void> {
const date = followUpAt.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const response = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': this.apiKey,
},
body: JSON.stringify({
sender: { name: this.fromName, email: this.fromEmail },
to: [{ email: to }],
subject: `Reminder: Follow up on ${role} at ${company}`,
htmlContent: `<p>This is a reminder to follow up on your <strong>${role}</strong> application at <strong>${company}</strong>.</p><p>Your scheduled follow-up date is <strong>${date}</strong>.</p>`,
}),
});
if (!response.ok && response.status !== 201) {
const body = await response.text();
throw new Error(`Brevo API error ${response.status}: ${body}`);
}
}
}
2 changes: 2 additions & 0 deletions apps/api/src/use-cases/ports/IApplicationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ export interface IApplicationRepository {
create(data: CreateApplicationData): Promise<Application>;
update(id: string, data: UpdateApplicationData): Promise<Application>;
delete(id: string): Promise<void>;
findDueForReminder(): Promise<Application[]>;
updateReminderSentAt(id: string, sentAt: Date): Promise<void>;
}
8 changes: 8 additions & 0 deletions apps/api/src/use-cases/ports/IEmailService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface IEmailService {
sendFollowUpReminder(
to: string,
company: string,
role: string,
followUpAt: Date,
): Promise<void>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { IApplicationRepository } from '@/use-cases/ports/IApplicationRepository.js';
import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js';
import type { IEmailService } from '@/use-cases/ports/IEmailService.js';

interface Deps {
applicationRepository: IApplicationRepository;
userRepository: IUserRepository;
emailService: IEmailService;
}

export class SendFollowUpRemindersUseCase {
constructor(private readonly deps: Deps) {}

async execute(): Promise<void> {
const apps = await this.deps.applicationRepository.findDueForReminder();
for (const app of apps) {
const user = await this.deps.userRepository.findById(app.userId);
if (!user) continue;
try {
await this.deps.emailService.sendFollowUpReminder(
user.email,
app.company,
app.role,
app.followUpAt!,
);
await this.deps.applicationRepository.updateReminderSentAt(app.id, new Date());
} catch {
// continue — one failure shouldn't block the rest
}
}
}
}
Loading
Loading