Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
45c9a41
feat: add admin approve modal for enableAutopublish
KrissDrawing May 6, 2026
25af5b1
refactor: rename id's
KrissDrawing May 6, 2026
01cd512
test: use new approve and publish path
KrissDrawing May 6, 2026
4e4de65
test: fake time inside test
KrissDrawing May 11, 2026
90525d5
fix: use timezone for scheduledPublishAt backend error
KrissDrawing May 11, 2026
8ec6d65
fix: modal copy work irrespective of timezone
KrissDrawing May 13, 2026
f74006a
fix: add scheduled listing status
KrissDrawing May 13, 2026
61a3a1b
fix: validate date from listing service
KrissDrawing May 18, 2026
35d4a1a
Merge branch 'main' into 6246/Admin-approval-dialogs
KrissDrawing May 18, 2026
e9b84d5
Merge branch 'main' into 6246/Admin-approval-dialogs
KrissDrawing May 19, 2026
20cf9df
Merge branch '6246/Admin-approval-dialogs' into 6247/New-listing-status
KrissDrawing May 19, 2026
54a8184
fix: remove actions for scheduled state
KrissDrawing May 19, 2026
1288f27
fix: handle scheduled public at for create listing
KrissDrawing May 19, 2026
14e84cd
Merge branch '6246/Admin-approval-dialogs' into 6247/New-listing-status
KrissDrawing May 19, 2026
4360038
fix: add scheduled action buttons with dialogs
KrissDrawing May 26, 2026
dc2c0a4
fix: publish listing when remove scheduled date on save
KrissDrawing May 26, 2026
461c788
fix: save when scheduledPublishedAt removed
KrissDrawing May 26, 2026
633e2c9
Merge branch 'main' into 6181/Admin-updates-when-in-scheduled-status
KrissDrawing May 27, 2026
de996da
fix: add cron job for autopublish
KrissDrawing May 29, 2026
4e9cffb
fix: update comment
KrissDrawing May 29, 2026
952e771
Merge branch 'main' into 6213/Autopublish-cron-job
KrissDrawing Jun 1, 2026
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
4 changes: 4 additions & 0 deletions api/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ PARTNERS_PORTAL_URL=http://localhost:3001
EMAIL_API_KEY=SG.ExampleApiKey
# controls the repetition of the listing cron job
LISTING_PROCESSING_CRON_STRING=0 * * * *
# controls the primary scheduled listing auto-publish cron job
LISTING_SCHEDULED_PUBLISH_CRON_STRING=1 0 * * *
# controls the retry scheduled listing auto-publish cron job
LISTING_SCHEDULED_PUBLISH_RETRY_CRON_STRING=0 1 * * *
# controls the repetition of the lottery publish cron job
LOTTERY_PUBLISH_PROCESSING_CRON_STRING=58 23 * * *
# controls the repetition of the lottery cron job
Expand Down
99 changes: 99 additions & 0 deletions api/src/services/listing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ includeViews.full = {
};

const LISTING_CRON_JOB_NAME = 'LISTING_CRON_JOB';
const LISTING_SCHEDULED_PUBLISH_CRON_JOB_NAME =
'LISTING_SCHEDULED_PUBLISH_CRON_STRING';
const LISTING_SCHEDULED_PUBLISH_RETRY_CRON_JOB_NAME =
'LISTING_SCHEDULED_PUBLISH_RETRY_CRON_STRING';
/*
this is the service for listings
it handles all the backend's business logic for reading in listing(s)
Expand Down Expand Up @@ -268,6 +272,16 @@ export class ListingService implements OnModuleInit {
process.env.LISTING_PROCESSING_CRON_STRING,
this.closeListings.bind(this),
);
this.cronJobService.startCronJob(
LISTING_SCHEDULED_PUBLISH_CRON_JOB_NAME,
process.env.LISTING_SCHEDULED_PUBLISH_CRON_STRING,
this.publishScheduledListingsCronJob.bind(this),
);
this.cronJobService.startCronJob(
LISTING_SCHEDULED_PUBLISH_RETRY_CRON_JOB_NAME,
process.env.LISTING_SCHEDULED_PUBLISH_RETRY_CRON_STRING,
this.publishScheduledListingsRetryCronJob.bind(this),
);
}

/*
Expand Down Expand Up @@ -3335,6 +3349,91 @@ export class ListingService implements OnModuleInit {
};
}

/**
* Runs the cron job to publish listings in 'scheduled' status whose
* scheduledPublishAt date is in the past. There are two cron jobs:
* - primary cron job (should run after midnight ex. 12:01 AM)
* - retry cron job (should run after the primary cron job within 2 hours ex. 1:00 AM)
*/
async publishScheduledListingsCronJob(): Promise<SuccessDTO> {
this.logger.warn('publishScheduledListingsCron job running');
await this.cronJobService.markCronJobAsStarted(
LISTING_SCHEDULED_PUBLISH_CRON_JOB_NAME,
);

return this.publishScheduledListings('publishScheduledListingsCron');
}

async publishScheduledListingsRetryCronJob(): Promise<SuccessDTO> {
this.logger.warn('publishScheduledListingsRetryCron job running');
await this.cronJobService.markCronJobAsStarted(
LISTING_SCHEDULED_PUBLISH_RETRY_CRON_JOB_NAME,
);

return this.publishScheduledListings('publishScheduledListingsRetryCron');
}

private async publishScheduledListings(logName: string): Promise<SuccessDTO> {
const listings = await this.prisma.listings.findMany({
select: { id: true },
where: {
status: ListingsStatusEnum.scheduled,
AND: [
{ scheduledPublishAt: { not: null } },
{ scheduledPublishAt: { lte: new Date() } },
],
},
});

const listingIds = listings.map((listing) => listing.id);
this.logger.warn(
`${logName} found ${listingIds.length} listing(s) ready to publish`,
);

if (listingIds.length > 0) {
this.logger.log(
`${logName} listing IDs to publish: ${listingIds.join(', ')}`,
);
}

for (const listingId of listingIds) {
await this.snapshotCreateService.createListingSnapshot(listingId);
}

const res = await this.prisma.listings.updateMany({
data: {
status: ListingsStatusEnum.active,
publishedAt: new Date(),
},
where: { id: { in: listingIds } },
});

if (listingIds.length > 0) {
await this.prisma.activityLog.createMany({
data: listingIds.map((id) => ({
module: 'listing',
recordId: id,
action: 'update',
metadata: { status: ListingsStatusEnum.active },
})),
});
}

this.logger.warn(
`${logName} published ${res?.count} listing(s) to active status`,
);

if (res?.count) {
await this.cachePurge(
ListingsStatusEnum.active,
ListingsStatusEnum.scheduled,
'',
);
}

return { success: true };
}

/**
*
* @param listingId
Expand Down
131 changes: 131 additions & 0 deletions api/test/unit/services/listing.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7059,6 +7059,137 @@ describe('Testing listing service', () => {
});
});

describe('Test publishScheduledListings', () => {
it('should publish due scheduled listings, write snapshots, activity logs and purge cache', async () => {
prisma.listings.findMany = jest
.fn()
.mockResolvedValue([
{ id: 'scheduled-id-1' },
{ id: 'scheduled-id-2' },
]);
prisma.listings.findUnique = jest
.fn()
.mockResolvedValue({ id: 'scheduled-id-1' });
prisma.listings.updateMany = jest.fn().mockResolvedValue({ count: 2 });
prisma.activityLog.createMany = jest.fn().mockResolvedValue({ count: 2 });
prisma.cronJob.findFirst = jest
.fn()
.mockResolvedValue({ id: randomUUID() });
prisma.cronJob.update = jest.fn().mockResolvedValue(true);
prisma.listingSnapshot.create = jest
.fn()
.mockResolvedValue({ id: 'snapshot-id' });

process.env.PROXY_URL = 'https://www.google.com';
await service.publishScheduledListingsCronJob();

expect(prisma.listings.findMany).toHaveBeenCalledWith({
select: { id: true },
where: {
status: ListingsStatusEnum.scheduled,
AND: [
{ scheduledPublishAt: { not: null } },
{ scheduledPublishAt: { lte: expect.any(Date) } },
],
},
});
expect(prisma.listings.updateMany).toHaveBeenCalledWith({
data: {
status: ListingsStatusEnum.active,
publishedAt: expect.any(Date),
},
where: { id: { in: ['scheduled-id-1', 'scheduled-id-2'] } },
});
expect(prisma.activityLog.createMany).toHaveBeenCalledWith({
data: [
{
module: 'listing',
recordId: 'scheduled-id-1',
action: 'update',
metadata: { status: ListingsStatusEnum.active },
},
{
module: 'listing',
recordId: 'scheduled-id-2',
action: 'update',
metadata: { status: ListingsStatusEnum.active },
},
],
});
expect(prisma.listingSnapshot.create).toHaveBeenCalledTimes(2);
expect(httpServiceMock.request).toHaveBeenCalledWith({
baseURL: 'https://www.google.com',
method: 'PURGE',
url: `/listings?*`,
});
expect(prisma.cronJob.findFirst).toHaveBeenCalled();
expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({
where: {
name: 'LISTING_SCHEDULED_PUBLISH_CRON_STRING',
},
});
expect(prisma.cronJob.update).toHaveBeenCalled();
process.env.PROXY_URL = undefined;
});

it('should not purge cache or write activity logs when no listings are due', async () => {
prisma.listings.findMany = jest.fn().mockResolvedValue([]);
prisma.listings.updateMany = jest.fn().mockResolvedValue({ count: 0 });
prisma.cronJob.findFirst = jest
.fn()
.mockResolvedValue({ id: randomUUID() });
prisma.cronJob.update = jest.fn().mockResolvedValue(true);

process.env.PROXY_URL = 'https://www.google.com';
await service.publishScheduledListingsCronJob();

expect(prisma.listings.updateMany).toHaveBeenCalledWith({
data: {
status: ListingsStatusEnum.active,
publishedAt: expect.any(Date),
},
where: { id: { in: [] } },
});
expect(httpServiceMock.request).not.toHaveBeenCalled();
expect(prisma.activityLog.createMany).not.toHaveBeenCalled();
process.env.PROXY_URL = undefined;
});

it('should work identically when called by the retry cron job', async () => {
prisma.listings.findMany = jest
.fn()
.mockResolvedValue([{ id: 'scheduled-id-1' }]);
prisma.listings.findUnique = jest
.fn()
.mockResolvedValue({ id: 'scheduled-id-1' });
prisma.listings.updateMany = jest.fn().mockResolvedValue({ count: 1 });
prisma.activityLog.createMany = jest.fn().mockResolvedValue({ count: 1 });
prisma.cronJob.findFirst = jest
.fn()
.mockResolvedValue({ id: randomUUID() });
prisma.cronJob.update = jest.fn().mockResolvedValue(true);
prisma.listingSnapshot.create = jest
.fn()
.mockResolvedValue({ id: 'snapshot-id' });

await service.publishScheduledListingsRetryCronJob();

expect(prisma.listings.updateMany).toHaveBeenCalledWith({
data: {
status: ListingsStatusEnum.active,
publishedAt: expect.any(Date),
},
where: { id: { in: ['scheduled-id-1'] } },
});
expect(prisma.cronJob.update).toHaveBeenCalled();
expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({
where: {
name: 'LISTING_SCHEDULED_PUBLISH_RETRY_CRON_STRING',
},
});
});
});

describe('Test updateListingEvents endpoint', () => {
it('should clear asset from listing events if they are present', async () => {
prisma.listingEvents.findMany = jest.fn().mockResolvedValue([
Expand Down
Loading