Skip to content

Commit

Permalink
feat(content-releases): Add new scheduling service (#19414)
Browse files Browse the repository at this point in the history
* feat(content-releases): Add new scheduling service

* apply remi's feedback
  • Loading branch information
Feranchz committed Feb 6, 2024
1 parent 287aae0 commit 53caa29
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 1 deletion.
12 changes: 12 additions & 0 deletions docs/docs/docs/01-core/content-releases/01-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ Exposes validation functions to run before performing operations on a Release
packages/core/content-releases/server/src/services/validation.ts
```

### Scheduling

:::caution
Scheduling is still under development, but you can try it **at your own risk** with future flags. The future flag to enable scheduling is `contentReleasesScheduling`.
:::

Exposes methods to schedule release date for releases.

```
packages/core/content-releases/server/src/services/scheduling.ts
```

## Migrations

We have two migrations that we run every time we sync the content types.
Expand Down
1 change: 1 addition & 0 deletions packages/core/content-releases/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"axios": "1.6.0",
"formik": "2.4.0",
"lodash": "4.17.21",
"node-schedule": "2.1.0",
"react-intl": "6.4.1",
"react-redux": "8.1.1",
"yup": "0.32.9"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { scheduleJob } from 'node-schedule';
import createSchedulingService from '../scheduling';

const baseStrapiMock = {
features: {
future: {
isEnabled: jest.fn().mockReturnValue(true),
},
},
};

jest.mock('node-schedule', () => ({
scheduleJob: jest.fn(),
}));

describe('Scheduling service', () => {
describe('set', () => {
it('should throw an error if the release does not exist', async () => {
const strapiMock = {
...baseStrapiMock,
db: {
query: jest.fn(() => ({
findOne: jest.fn().mockReturnValue(null),
})),
},
};

// @ts-expect-error Ignore missing properties
const schedulingService = createSchedulingService({ strapi: strapiMock });
expect(() => schedulingService.set('1', new Date())).rejects.toThrow(
'No release found for id 1'
);
});

it('should cancel the previous job if it exists and create the new one', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);

const strapiMock = {
...baseStrapiMock,
db: {
query: jest.fn(() => ({
findOne: jest.fn().mockReturnValue({ id: 1 }),
})),
},
};

const oldJobDate = new Date();
const newJobDate = new Date(oldJobDate.getTime() + 1000);

// @ts-expect-error Ignore missing properties
const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', oldJobDate);
expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(oldJobDate, expect.any(Function));

const oldJob = scheduledJobs.get('1')!;

await schedulingService.set('1', newJobDate);

expect(oldJob.cancel).toHaveBeenCalled();
expect(mockScheduleJob).toHaveBeenCalledWith(newJobDate, expect.any(Function));
});

it('should create a new job', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);

const strapiMock = {
...baseStrapiMock,
db: {
query: jest.fn(() => ({
findOne: jest.fn().mockReturnValue({ id: 1 }),
})),
},
};

const date = new Date();

// @ts-expect-error Ignore missing properties
const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', date);
expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(date, expect.any(Function));
});
});

describe('cancel', () => {
it('should cancel the job if it exists', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);

const strapiMock = {
...baseStrapiMock,
db: {
query: jest.fn(() => ({
findOne: jest.fn().mockReturnValue({ id: 1 }),
})),
},
};

const date = new Date();

// @ts-expect-error Ignore missing properties
const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', date);
expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(date, expect.any(Function));

schedulingService.cancel('1');
expect(scheduledJobs.size).toBe(0);
});
});
});
2 changes: 2 additions & 0 deletions packages/core/content-releases/server/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import release from './release';
import releaseValidation from './validation';
import scheduling from './scheduling';

export const services = {
release,
'release-validation': releaseValidation,
...(strapi.features.future.isEnabled('contentReleasesScheduling') ? { scheduling } : {}),
};
53 changes: 53 additions & 0 deletions packages/core/content-releases/server/src/services/scheduling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { scheduleJob, Job } from 'node-schedule';
import { LoadedStrapi } from '@strapi/types';

import { errors } from '@strapi/utils';
import { Release } from '../../../shared/contracts/releases';
import { getService } from '../utils';
import { RELEASE_MODEL_UID } from '../constants';

const createSchedulingService = ({ strapi }: { strapi: LoadedStrapi }) => {
const scheduledJobs = new Map<Release['id'], Job>();

return {
async set(releaseId: Release['id'], scheduleDate: Date) {
const release = await strapi.db
.query(RELEASE_MODEL_UID)
.findOne({ where: { id: releaseId, releasedAt: null } });

if (!release) {
throw new errors.NotFoundError(`No release found for id ${releaseId}`);
}

const job = scheduleJob(scheduleDate, async () => {
try {
await getService('release').publish(releaseId);
// @TODO: Trigger webhook with success message
} catch (error) {
// @TODO: Trigger webhook with error message
}

this.cancel(releaseId);
});

if (scheduledJobs.has(releaseId)) {
this.cancel(releaseId);
}

scheduledJobs.set(releaseId, job);

return scheduledJobs;
},

cancel(releaseId: Release['id']) {
if (scheduledJobs.has(releaseId)) {
scheduledJobs.get(releaseId)!.cancel();
scheduledJobs.delete(releaseId);
}

return scheduledJobs;
},
};
};

export default createSchedulingService;
2 changes: 1 addition & 1 deletion packages/core/content-releases/server/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const getService = (
name: 'release' | 'release-validation' | 'release-action' | 'event-manager',
name: 'release' | 'release-validation' | 'scheduling' | 'release-action' | 'event-manager',
{ strapi } = { strapi: global.strapi }
) => {
return strapi.plugin('content-releases').service(name);
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7924,6 +7924,7 @@ __metadata:
koa: "npm:2.13.4"
lodash: "npm:4.17.21"
msw: "npm:1.3.0"
node-schedule: "npm:2.1.0"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-intl: "npm:6.4.1"
Expand Down

0 comments on commit 53caa29

Please sign in to comment.