show created jobs under recurring rule

This commit is contained in:
Ramnique Singh 2025-08-13 10:36:16 +05:30
parent eda3f3821f
commit c030c4fa83
7 changed files with 130 additions and 23 deletions

View file

@ -4,12 +4,15 @@ import { container } from "@/di/container";
import { IListJobsController } from "@/src/interface-adapters/controllers/jobs/list-jobs.controller";
import { IFetchJobController } from "@/src/interface-adapters/controllers/jobs/fetch-job.controller";
import { authCheck } from "./auth_actions";
import { JobFiltersSchema } from "@/src/application/repositories/jobs.repository.interface";
import { z } from "zod";
const listJobsController = container.resolve<IListJobsController>('listJobsController');
const fetchJobController = container.resolve<IFetchJobController>('fetchJobController');
export async function listJobs(request: {
projectId: string,
filters?: z.infer<typeof JobFiltersSchema>,
cursor?: string,
limit?: number,
}) {
@ -19,6 +22,7 @@ export async function listJobs(request: {
caller: 'user',
userId: user._id,
projectId: request.projectId,
filters: request.filters,
cursor: request.cursor,
limit: request.limit,
});

View file

@ -10,6 +10,7 @@ import Link from "next/link";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { Spinner } from "@heroui/react";
import { z } from "zod";
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
const router = useRouter();
@ -263,6 +264,18 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={{ recurringJobRuleId: ruleId }}
showTitle={false}
/>
</div>
</div>
</div>
</Panel>

View file

@ -6,12 +6,19 @@ import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { listJobs } from "@/app/actions/job_actions";
import { z } from "zod";
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
import { ListedJobItem, JobFilters } from "@/src/application/repositories/jobs.repository.interface";
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
type ListedItem = z.infer<typeof ListedJobItem>;
export function JobsList({ projectId }: { projectId: string }) {
interface JobsListProps {
projectId: string;
filters?: JobFilters;
showTitle?: boolean;
customTitle?: string;
}
export function JobsList({ projectId, filters, showTitle = true, customTitle }: JobsListProps) {
const [items, setItems] = useState<ListedItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
@ -19,14 +26,22 @@ export function JobsList({ projectId }: { projectId: string }) {
const [hasMore, setHasMore] = useState<boolean>(false);
const fetchPage = useCallback(async (cursorArg?: string | null) => {
const res = await listJobs({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
const res = await listJobs({
projectId,
filters,
cursor: cursorArg ?? undefined,
limit: 20
});
return res;
}, [projectId]);
}, [projectId, filters]);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
setItems([]);
setCursor(null);
setHasMore(false);
const res = await fetchPage(null);
if (ignore) return;
setItems(res.items);
@ -35,7 +50,7 @@ export function JobsList({ projectId }: { projectId: string }) {
setLoading(false);
})();
return () => { ignore = true; };
}, [fetchPage]);
}, [fetchPage, filters]);
const loadMore = useCallback(async () => {
if (!cursor) return;
@ -111,14 +126,21 @@ export function JobsList({ projectId }: { projectId: string }) {
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
JOBS
showTitle ? (
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{customTitle || "JOBS"}
</div>
</div>
</div>
) : null
}
rightActions={
<div className="flex items-center gap-3">
{filters && items.length > 0 && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{items.length} job{items.length !== 1 ? 's' : ''} found
</div>
)}
{/* Reserved for future actions */}
</div>
}
@ -132,7 +154,9 @@ export function JobsList({ projectId }: { projectId: string }) {
</div>
)}
{!loading && items.length === 0 && (
<p className="mt-4 text-center">No jobs yet.</p>
<p className="mt-4 text-center">
{filters ? "No jobs found matching the current filters." : "No jobs yet."}
</p>
)}
{!loading && items.length > 0 && (
<div className="flex flex-col gap-8">

View file

@ -23,6 +23,31 @@ export const ListedJobItem = Job.pick({
updatedAt: true,
});
/**
* Schema for filtering jobs when listing.
* This schema is designed to be extensible for future filtering criteria.
*/
export const JobFiltersSchema = z.object({
// Filter by job status
status: z.enum(["pending", "running", "completed", "failed"]).optional(),
// Filter by recurring job rule ID
recurringJobRuleId: z.string().optional(),
// Filter by composio trigger deployment ID
composioTriggerDeploymentId: z.string().optional(),
// Filter by date range
createdAfter: z.string().datetime().optional(),
createdBefore: z.string().datetime().optional(),
// Extensible: add more filters here as needed
// Example: errorMessage: z.string().optional(),
// Example: priority: z.enum(["low", "medium", "high"]).optional(),
}).strict();
export type JobFilters = z.infer<typeof JobFiltersSchema>;
/**
* Schema for updating an existing job.
* Defines the fields that can be updated for a job.
@ -102,12 +127,18 @@ export interface IJobsRepository {
release(id: string): Promise<void>;
/**
* Lists jobs for a specific project with pagination.
* Lists jobs for a specific project with optional filtering and pagination.
*
* @param projectId - The unique identifier of the project
* @param filters - Optional filters to apply to the job list
* @param cursor - Optional cursor for pagination
* @param limit - Maximum number of jobs to return (default: 50)
* @returns Promise resolving to a paginated list of jobs
*/
list(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
list(
projectId: string,
filters?: JobFilters,
cursor?: string,
limit?: number
): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
}

View file

@ -2,7 +2,7 @@ import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
import { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
import { IJobsRepository, ListedJobItem } from '../../repositories/jobs.repository.interface';
import { IJobsRepository, ListedJobItem, JobFilters, JobFiltersSchema } from '../../repositories/jobs.repository.interface';
import { Job } from '@/src/entities/models/job';
import { PaginatedList } from '@/src/entities/common/paginated-list';
@ -11,6 +11,7 @@ const inputSchema = z.object({
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
filters: JobFiltersSchema.optional(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
@ -54,6 +55,6 @@ export class ListJobsUseCase implements IListJobsUseCase {
await this.usageQuotaPolicy.assertAndConsume(projectId);
// fetch jobs for project
return await this.jobsRepository.list(projectId, request.cursor, limit);
return await this.jobsRepository.list(projectId, request.filters, request.cursor, limit);
}
}

View file

@ -1,7 +1,7 @@
import { z } from "zod";
import { ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb";
import { CreateJobSchema, IJobsRepository, ListedJobItem, UpdateJobSchema } from "@/src/application/repositories/jobs.repository.interface";
import { CreateJobSchema, IJobsRepository, ListedJobItem, UpdateJobSchema, JobFilters } from "@/src/application/repositories/jobs.repository.interface";
import { Job } from "@/src/entities/models/job";
import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
import { NotFoundError } from "@/src/entities/errors/common";
@ -198,11 +198,43 @@ export class MongoDBJobsRepository implements IJobsRepository {
}
/**
* Lists jobs for a specific project with pagination.
* Lists jobs for a specific project with optional filtering and pagination.
*/
async list(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
async list(
projectId: string,
filters?: JobFilters,
cursor?: string,
limit: number = 50
): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
const query: any = { projectId };
const _limit = Math.min(limit, 50);
// Apply filters if provided
if (filters) {
if (filters.status) {
query.status = filters.status;
}
if (filters.recurringJobRuleId) {
query["reason.type"] = "recurring_job_rule";
query["reason.ruleId"] = filters.recurringJobRuleId;
}
if (filters.composioTriggerDeploymentId) {
query["reason.type"] = "composio_trigger";
query["reason.triggerDeploymentId"] = filters.composioTriggerDeploymentId;
}
if (filters.createdAfter) {
query.createdAt = { ...query.createdAt, $gte: filters.createdAfter };
}
if (filters.createdBefore) {
query.createdAt = { ...query.createdAt, $lte: filters.createdBefore };
}
}
if (cursor) {
query._id = { $lt: new ObjectId(cursor) };
}
@ -210,7 +242,7 @@ export class MongoDBJobsRepository implements IJobsRepository {
const results = await this.collection
.find(query)
.sort({ _id: -1 })
.limit(limit + 1) // Fetch one extra to determine if there's a next page
.limit(_limit + 1) // Fetch one extra to determine if there's a next page
.project<z.infer<typeof ListedJobItem> & { _id: ObjectId }>({
_id: 1,
projectId: 1,
@ -221,8 +253,8 @@ export class MongoDBJobsRepository implements IJobsRepository {
})
.toArray();
const hasNextPage = results.length > limit;
const items = results.slice(0, limit).map(doc => {
const hasNextPage = results.length > _limit;
const items = results.slice(0, _limit).map(doc => {
const { _id, ...rest } = doc;
return {
...rest,
@ -232,7 +264,7 @@ export class MongoDBJobsRepository implements IJobsRepository {
return {
items,
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
nextCursor: hasNextPage ? results[_limit - 1]._id.toString() : null,
};
}
}

View file

@ -3,13 +3,14 @@ import z from "zod";
import { IListJobsUseCase } from "@/src/application/use-cases/jobs/list-jobs.use-case";
import { Job } from "@/src/entities/models/job";
import { PaginatedList } from "@/src/entities/common/paginated-list";
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
import { JobFiltersSchema, ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
filters: JobFiltersSchema.optional(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
@ -35,7 +36,7 @@ export class ListJobsController implements IListJobsController {
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
const { caller, userId, apiKey, projectId, filters, cursor, limit } = result.data;
// execute use case
return await this.listJobsUseCase.execute({
@ -43,6 +44,7 @@ export class ListJobsController implements IListJobsController {
userId,
apiKey,
projectId,
filters,
cursor,
limit,
});