diff --git a/package-lock.json b/package-lock.json index b4910a4..b6a4bb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1685,6 +1685,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -1913,6 +1914,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2025,6 +2027,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3783,6 +3786,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.4.tgz", "integrity": "sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.105.4", "@supabase/functions-js": "2.105.4", @@ -4118,6 +4122,7 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4146,6 +4151,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4858,6 +4864,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5375,6 +5382,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6579,6 +6587,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6653,6 +6662,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6821,6 +6831,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8791,6 +8802,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9557,6 +9569,7 @@ "integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "14.2.5", "@swc/helpers": "0.5.5", @@ -10184,6 +10197,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10354,6 +10368,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -10417,6 +10432,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10585,6 +10601,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10597,6 +10614,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12030,6 +12048,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12140,6 +12159,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -12696,6 +12716,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12877,6 +12898,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13390,6 +13412,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/src/app/(app)/settings/usage/page.tsx b/src/app/(app)/settings/usage/page.tsx index db3bb70..ffc2d8b 100644 --- a/src/app/(app)/settings/usage/page.tsx +++ b/src/app/(app)/settings/usage/page.tsx @@ -14,6 +14,8 @@ const KIND_LABEL: Record = { 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() { diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts index 5caeb7b..29260f6 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -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'; @@ -40,6 +45,7 @@ export const { GET, POST, PUT } = serve({ streakDetect, recsExpire, activityLogCleanup, + autoUnclaimStale, githubStatsSync, mentorPostComment, processIssueEvent, diff --git a/src/inngest/functions/maintenance.test.ts b/src/inngest/functions/maintenance.test.ts new file mode 100644 index 0000000..caa867f --- /dev/null +++ b/src/inngest/functions/maintenance.test.ts @@ -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); + }); +}); diff --git a/src/inngest/functions/maintenance.ts b/src/inngest/functions/maintenance.ts index 079712e..00d1b83 100644 --- a/src/inngest/functions/maintenance.ts +++ b/src/inngest/functions/maintenance.ts @@ -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 }; + }, +);