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
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ updates:
# patches) so the pair only moves atomically with the runtime upgrade
# (#2125) - a patch bump here without the override recreates the mismatch.
- dependency-name: 'playwright'
# ioredis 6 raised its node floor to >=20; the backend image runs node 18.
# 5.11.1 is the last 5.x release and carries AIKIDO-2026-538318 (medium)
# with no 5.x fix available, so the advisory only clears by taking 6.x,
# which the runtime upgrade (#2125) has to come first for. This is the
# same version main already runs, so the freeze holds parity rather than
# accepting anything new.
- dependency-name: 'ioredis'
update-types: ['version-update:semver-major']
# @babel/core 8 cannot be installed while the app is on React Native
# 0.81: @react-native/babel-preset asserts Babel "^7.0.0-0" at load time
# and throws "Requires Babel ^7.0.0-0, but was loaded with 8.0.1" for
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"firebase-admin": "^13.6.0",
"google-auth-library": "^10.9.1",
"helmet": "^8.3.0",
"ioredis": "^5.11.1",
"jsonwebtoken": "9.0.3",
"jwks-rsa": "^4.1.0",
"moment": "2.30.1",
Expand Down
25 changes: 25 additions & 0 deletions apps/backend/src/queues/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,33 @@ import { registerAppointmentSchedulers } from "./appointment.scheduler";
import { registerIdexxReferenceScheduler } from "./idexx-reference.scheduler";
import { registerLabStatusScheduler } from "./lab-status.scheduler";
import { registerLabResultsScheduler } from "./lab-results.scheduler";
import { AppointmentQueue } from "./appointment.queue";
import { IdexxReferenceQueue } from "./idexx-reference.queue";
import { LabResultsQueue } from "./lab-results.queue";
import { LabStatusQueue } from "./lab-status.queue";
import { TaskScheduleQueue } from "./task-schedule.queue";
import { TaskRecurrenceQueue, TaskReminderQueue } from "./task.queues";
import {
pruneLegacyRepeatablesAcross,
SchedulerCapableQueue,
} from "./legacy-repeatables";

export const scheduledQueues = [
AppointmentQueue,
IdexxReferenceQueue,
LabResultsQueue,
LabStatusQueue,
TaskScheduleQueue,
TaskRecurrenceQueue,
TaskReminderQueue,
] as unknown as SchedulerCapableQueue[];

export async function initQueues() {
// Must run before the upserts: bullmq 5 keyed its repeatables by an md5 of
// the job options, not by the id, so an upsert adds a second entry instead of
// replacing the old one and both keep firing. See legacy-repeatables.ts.
await pruneLegacyRepeatablesAcross(scheduledQueues);

await registerTaskSchedulers();
await registerTaskScheduleSchedulers();
await registerAppointmentSchedulers();
Expand Down
94 changes: 94 additions & 0 deletions apps/backend/src/queues/legacy-repeatables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import logger from "src/utils/logger";

/**
* Remove the repeatable-job entries bullmq 5 left behind.
*
* bullmq 5 registered recurring work with `Queue.add(name, data, { repeat,
* jobId })`, and did NOT key that entry by `jobId`: it built
* `name:jobId:endDate:tz:every` and stored the entry under the md5 of that
* string. bullmq 6 replaced the API with `Queue.upsertJobScheduler(id, ...)`,
* which keys by the plain id.
*
* Both live in the same `repeat` sorted set, so carrying the same id string
* across the migration is not enough: the upsert writes a new entry beside the
* md5 one rather than replacing it, and bullmq 6 still recognises the legacy
* shape and keeps scheduling from it. Left alone, every recurring job in the
* app would fire twice, for as long as the old entry survives in Redis.
*
* This prunes the legacy entries once, at boot, before the schedulers are
* upserted. It only removes keys that are exactly a 32 character hex digest,
* which is the md5 shape bullmq 5 produced and which none of the ids this app
* registers can collide with, so a scheduler this app owns is never removed
* even if the expected list is ever incomplete.
*/

const LEGACY_MD5_KEY = /^[0-9a-f]{32}$/;

export interface SchedulerCapableQueue {
name: string;
getJobSchedulers(): Promise<Array<{ key: string } | null | undefined>>;
removeJobScheduler(key: string): Promise<boolean>;
}

export function isLegacyRepeatableKey(key: unknown): key is string {
return typeof key === "string" && LEGACY_MD5_KEY.test(key);
}

export async function pruneLegacyRepeatables(
queue: SchedulerCapableQueue,
): Promise<string[]> {
const schedulers = await queue.getJobSchedulers();
const removed: string[] = [];

for (const scheduler of schedulers ?? []) {
const key = scheduler?.key;
if (!isLegacyRepeatableKey(key)) {
continue;
}

// A failure here must not stop the boot: the worst case of leaving one
// entry behind is a duplicate job, whereas throwing takes the API down.
// removeJobScheduler reports whether it actually removed anything, and a
// false is not an error - it means the entry was already gone - but it must
// not be counted as a removal either.
try {
const wasRemoved = await queue.removeJobScheduler(key);

if (wasRemoved) {
removed.push(key);
} else {
logger.warn(
`Legacy repeatable ${key} on queue ${queue.name} was not removed; it may already be gone`,
);
}
} catch (error) {
logger.warn(
`Could not remove legacy repeatable ${key} on queue ${queue.name}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

if (removed.length > 0) {
logger.info(
`🧹 Removed ${removed.length} bullmq 5 repeatable entr${
removed.length === 1 ? "y" : "ies"
} from queue ${queue.name}`,
);
}

return removed;
}

export async function pruneLegacyRepeatablesAcross(
queues: SchedulerCapableQueue[],
): Promise<string[]> {
const removed: string[] = [];

for (const queue of queues) {
removed.push(...(await pruneLegacyRepeatables(queue)));
}

return removed;
}
4 changes: 2 additions & 2 deletions apps/backend/src/services/companion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { AuditTrailService } from "./audit-trail.service";
import { ParentService } from "./parent.service";
import { buildS3Key, moveFile } from "src/middlewares/upload";
import { escapeRegExp } from "../utils/escape-regexp";
import { escapeLikePattern } from "../utils/escape-like";
import logger from "src/utils/logger";
import { TaskLibraryService } from "./taskLibrary.service";
import { CreateFromLibraryInput, TaskService } from "./task.service";
Expand Down Expand Up @@ -565,7 +565,7 @@ export const CompanionService = {
throw new CompanionServiceError("Name is required for searching.", 400);
}

const safe = escapeRegExp(trimmed);
const safe = escapeLikePattern(trimmed);
const documents = await prisma.patient.findMany({
where: { name: { contains: safe, mode: "insensitive" } },
});
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/services/parent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { AuditTrailService } from "./audit-trail.service";
import { AuthUserMobileService } from "./authUserMobile.service";
import { buildS3Key, moveFile } from "src/middlewares/upload";
import logger from "src/utils/logger";
import { escapeRegExp } from "../utils/escape-regexp";
import { escapeLikePattern } from "../utils/escape-like";

export class ParentServiceError extends Error {
constructor(
Expand Down Expand Up @@ -639,7 +639,7 @@ export const ParentService = {
throw new ParentServiceError("Name is required for searching.", 400);
}

const safe = escapeRegExp(trimmed);
const safe = escapeLikePattern(trimmed);

const docs = await prisma.parent.findMany({
where: {
Expand Down
26 changes: 26 additions & 0 deletions apps/backend/src/utils/escape-like.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Escape a user-supplied string so Prisma's `contains` treats it as a literal.
*
* `contains` compiles to a PostgreSQL `LIKE`/`ILIKE` pattern, and Prisma passes
* the value through without escaping, so `%` and `_` in user input act as
* wildcards: a search for "a_c" also matches "abc", and a search for "100%"
* matches everything starting with "100". Backslash is PostgreSQL's default
* LIKE escape character, so escaping these three characters with it makes the
* pattern mean exactly the text the user typed.
*
* This replaces an earlier regex escape on the same call sites. That was always
* the wrong tool - `contains` is a LIKE pattern, not a regular expression - and
* it only appeared to work because escaping an ordinary character with a
* backslash is a no-op in LIKE. It stopped appearing to work when the escape
* started emitting `\x2d` for a hyphen, which LIKE reads as the literal text
* `x2d`, so "Jean-Luc" searched for "Jeanx2dLuc" and matched nothing.
*/
export function escapeLikePattern(value: string): string {
if (typeof value !== "string") {
throw new TypeError("Expected a string");
}

return value.replace(/[\\%_]/g, "\\$&");

Check warning on line 23 in apps/backend/src/utils/escape-like.ts

View check run for this annotation

SonarQubeCloud / [backend] SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=yosemitecrew_Yosemite-Crew_Backend&issues=AaAE_YAWRdE24u2T7gtc&open=AaAE_YAWRdE24u2T7gtc&pullRequest=2194
}

export default escapeLikePattern;
23 changes: 0 additions & 23 deletions apps/backend/src/utils/escape-regexp.ts

This file was deleted.

Loading
Loading