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
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/app/(app)/settings/usage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const KIND_LABEL: Record<string, string> = {
pr_backfilled: 'Backfilled PR history',
pr_mentor_verified: 'Your PR was mentor-verified',
xp_tripwire: 'Daily XP tripwire',
claim_reset_stale: 'Claim reset due to inactivity',
claim_warning_stale: 'Inactivity warning for claimed issue',
};

export default async function UsagePage() {
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import {
processMemberEvent,
} from '@/inngest/functions/process-membership-events';
import { prBackfill } from '@/inngest/functions/pr-backfill';
import { streakDetect, recsExpire, activityLogCleanup } from '@/inngest/functions/maintenance';
import {
streakDetect,
recsExpire,
activityLogCleanup,
autoUnclaimStale,
} from '@/inngest/functions/maintenance';
import { githubStatsSync } from '@/inngest/functions/github-stats-sync';
import { mentorPostComment } from '@/inngest/functions/mentor-post-comment';
import { processIssueEvent } from '@/inngest/functions/process-issue-event';
Expand All @@ -40,6 +45,7 @@ export const { GET, POST, PUT } = serve({
streakDetect,
recsExpire,
activityLogCleanup,
autoUnclaimStale,
githubStatsSync,
mentorPostComment,
processIssueEvent,
Expand Down
66 changes: 66 additions & 0 deletions src/inngest/functions/maintenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { autoUnclaimStale } from './maintenance';
import { sb, wire, step } from './test-helpers';

// Mock external dependencies.
vi.mock('@/lib/supabase/service', () => ({ getServiceSupabase: vi.fn() }));
vi.mock('../client', () => ({
inngest: { createFunction: (_c: unknown, _t: unknown, h: Function) => h },
}));

const run = autoUnclaimStale as unknown as (ctx: {
step: typeof step;
}) => Promise<{ unclaimed: number; warned: number }>;

describe('autoUnclaimStale', () => {
beforeEach(() => vi.clearAllMocks());

it('unclaims stale recommendations and logs activity, warns day-10 users', async () => {
const updateMock = vi.fn().mockResolvedValue({
data: [{ id: 1, user_id: 'u1' }],
error: null,
});
const selectMock = vi.fn().mockResolvedValue({
data: [{ id: 2, user_id: 'u2' }],
error: null,
});
const insertMock = vi.fn().mockResolvedValue({ error: null });

const recsTableMock = sb({
update: vi.fn(() => ({
eq: vi.fn(() => ({
is: vi.fn(() => ({
lt: vi.fn(() => ({
select: updateMock,
})),
})),
})),
})),
select: vi.fn(() => ({
eq: vi.fn(() => ({
is: vi.fn(() => ({
gte: vi.fn(() => ({
lt: selectMock,
})),
})),
})),
})),
});

const activityLogTableMock = sb({
insert: insertMock,
});

wire({
recommendations: recsTableMock,
activity_log: activityLogTableMock,
});

const result = await run({ step });

expect(result).toEqual({ unclaimed: 1, warned: 1 });
expect(updateMock).toHaveBeenCalled();
expect(selectMock).toHaveBeenCalled();
expect(insertMock).toHaveBeenCalledTimes(2);
});
});
78 changes: 78 additions & 0 deletions src/inngest/functions/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,81 @@ export const activityLogCleanup = inngest.createFunction(
});
},
);

const CLAIM_STALE_THRESHOLD_DAYS = 14;
const CLAIM_WARNING_THRESHOLD_DAYS = 10;

/**
* Auto-unclaim stale recommendations after 14 days without a linked PR
* and send warning notifications at day 10.
*/
export const autoUnclaimStale = inngest.createFunction(
{ id: 'auto-unclaim-stale' },
{ cron: '30 0 * * *' }, // 00:30 UTC daily
async ({ step }) => {
const unclaimResult = await step.run('unclaim-stale-recs', async () => {
const sb = getServiceSupabase();
if (!sb) throw new Error('service role missing');

const threshold = new Date(
Date.now() - CLAIM_STALE_THRESHOLD_DAYS * 24 * 3600 * 1000,
).toISOString();

const { data: updatedRecs, error } = await sb
.from('recommendations')
.update({ status: 'open', claimed_at: null })
.eq('status', 'claimed')
.is('linked_pr_url', null)
.lt('claimed_at', threshold)
.select('id, user_id');

if (error) throw new Error(`unclaim update failed: ${error.message}`);

if (updatedRecs && updatedRecs.length > 0) {
const logs = updatedRecs.map((rec) => ({
user_id: rec.user_id,
kind: 'claim_reset_stale',
detail: { recId: rec.id } as never,
}));
await sb.from('activity_log').insert(logs);
}

return { unclaimed: updatedRecs?.length ?? 0 };
});

const warnResult = await step.run('warn-stale-recs', async () => {
const sb = getServiceSupabase();
if (!sb) throw new Error('service role missing');

const warnMin = new Date(
Date.now() - (CLAIM_WARNING_THRESHOLD_DAYS + 1) * 24 * 3600 * 1000,
).toISOString();
const warnMax = new Date(
Date.now() - CLAIM_WARNING_THRESHOLD_DAYS * 24 * 3600 * 1000,
).toISOString();

const { data: toWarn, error } = await sb
.from('recommendations')
.select('id, user_id')
.eq('status', 'claimed')
.is('linked_pr_url', null)
.gte('claimed_at', warnMin)
.lt('claimed_at', warnMax);

if (error) throw new Error(`warn query failed: ${error.message}`);

if (toWarn && toWarn.length > 0) {
const warnLogs = toWarn.map((rec) => ({
user_id: rec.user_id,
kind: 'claim_warning_stale',
detail: { recId: rec.id, daysClaimed: CLAIM_WARNING_THRESHOLD_DAYS } as never,
}));
await sb.from('activity_log').insert(warnLogs);
}

return { warned: toWarn?.length ?? 0 };
});

return { ...unclaimResult, ...warnResult };
},
);
Loading