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

View file

@ -10,6 +10,7 @@ import Link from "next/link";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule"; import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { Spinner } from "@heroui/react"; import { Spinner } from "@heroui/react";
import { z } from "zod"; import { z } from "zod";
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) { export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
const router = useRouter(); 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><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div> </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>
</div> </div>
</Panel> </Panel>

View file

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

View file

@ -23,6 +23,31 @@ export const ListedJobItem = Job.pick({
updatedAt: true, 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. * Schema for updating an existing job.
* Defines the fields that can be updated for a job. * Defines the fields that can be updated for a job.
@ -102,12 +127,18 @@ export interface IJobsRepository {
release(id: string): Promise<void>; 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 projectId - The unique identifier of the project
* @param filters - Optional filters to apply to the job list
* @param cursor - Optional cursor for pagination * @param cursor - Optional cursor for pagination
* @param limit - Maximum number of jobs to return (default: 50) * @param limit - Maximum number of jobs to return (default: 50)
* @returns Promise resolving to a paginated list of jobs * @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 { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface'; import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy'; 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 { Job } from '@/src/entities/models/job';
import { PaginatedList } from '@/src/entities/common/paginated-list'; import { PaginatedList } from '@/src/entities/common/paginated-list';
@ -11,6 +11,7 @@ const inputSchema = z.object({
userId: z.string().optional(), userId: z.string().optional(),
apiKey: z.string().optional(), apiKey: z.string().optional(),
projectId: z.string(), projectId: z.string(),
filters: JobFiltersSchema.optional(),
cursor: z.string().optional(), cursor: z.string().optional(),
limit: z.number().optional(), limit: z.number().optional(),
}); });
@ -54,6 +55,6 @@ export class ListJobsUseCase implements IListJobsUseCase {
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsume(projectId);
// fetch jobs for project // 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 { z } from "zod";
import { ObjectId } from "mongodb"; import { ObjectId } from "mongodb";
import { db } from "@/app/lib/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 { Job } from "@/src/entities/models/job";
import { JobAcquisitionError } from "@/src/entities/errors/job-errors"; import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
import { NotFoundError } from "@/src/entities/errors/common"; 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 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) { if (cursor) {
query._id = { $lt: new ObjectId(cursor) }; query._id = { $lt: new ObjectId(cursor) };
} }
@ -210,7 +242,7 @@ export class MongoDBJobsRepository implements IJobsRepository {
const results = await this.collection const results = await this.collection
.find(query) .find(query)
.sort({ _id: -1 }) .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 }>({ .project<z.infer<typeof ListedJobItem> & { _id: ObjectId }>({
_id: 1, _id: 1,
projectId: 1, projectId: 1,
@ -221,8 +253,8 @@ export class MongoDBJobsRepository implements IJobsRepository {
}) })
.toArray(); .toArray();
const hasNextPage = results.length > limit; const hasNextPage = results.length > _limit;
const items = results.slice(0, limit).map(doc => { const items = results.slice(0, _limit).map(doc => {
const { _id, ...rest } = doc; const { _id, ...rest } = doc;
return { return {
...rest, ...rest,
@ -232,7 +264,7 @@ export class MongoDBJobsRepository implements IJobsRepository {
return { return {
items, 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 { IListJobsUseCase } from "@/src/application/use-cases/jobs/list-jobs.use-case";
import { Job } from "@/src/entities/models/job"; import { Job } from "@/src/entities/models/job";
import { PaginatedList } from "@/src/entities/common/paginated-list"; 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({ const inputSchema = z.object({
caller: z.enum(["user", "api"]), caller: z.enum(["user", "api"]),
userId: z.string().optional(), userId: z.string().optional(),
apiKey: z.string().optional(), apiKey: z.string().optional(),
projectId: z.string(), projectId: z.string(),
filters: JobFiltersSchema.optional(),
cursor: z.string().optional(), cursor: z.string().optional(),
limit: z.number().optional(), limit: z.number().optional(),
}); });
@ -35,7 +36,7 @@ export class ListJobsController implements IListJobsController {
if (!result.success) { if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`); 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 // execute use case
return await this.listJobsUseCase.execute({ return await this.listJobsUseCase.execute({
@ -43,6 +44,7 @@ export class ListJobsController implements IListJobsController {
userId, userId,
apiKey, apiKey,
projectId, projectId,
filters,
cursor, cursor,
limit, limit,
}); });