File size: 14,211 Bytes
6fb7573
53dc52c
6fb7573
175af2a
53dc52c
6fb7573
6cd3ae1
53dc52c
6cd3ae1
a030856
a0eafe5
53dc52c
 
 
 
 
 
 
6fb7573
 
a0eafe5
175af2a
53dc52c
 
6fb7573
 
6cd3ae1
 
6fb7573
 
 
 
 
 
 
 
6cd3ae1
 
6fb7573
 
 
 
 
 
fb074b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175af2a
 
 
 
 
 
 
 
2f666f7
175af2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88800df
 
 
 
 
 
 
 
2f666f7
88800df
 
 
 
 
 
53dc52c
 
 
 
 
 
6fb7573
53dc52c
 
 
 
 
 
a1056d7
9ffaa46
 
 
 
a1056d7
 
a030856
 
 
9ffaa46
a030856
a0eafe5
 
 
a030856
766c348
a030856
 
a1056d7
 
 
 
 
 
 
6cd3ae1
 
 
a1056d7
 
9ffaa46
 
175af2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6cd3ae1
 
6fb7573
 
 
 
 
 
 
 
53dc52c
6cd3ae1
 
53dc52c
6cd3ae1
 
53dc52c
 
 
 
 
6fb7573
 
6cd3ae1
 
6fb7573
 
 
 
 
6cd3ae1
 
 
 
53dc52c
 
6cd3ae1
6fb7573
6cd3ae1
 
6fb7573
 
 
 
 
 
53dc52c
 
 
6fb7573
53dc52c
6fb7573
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
import { Controller, Post, Get, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
import { ScraperService } from './scraper.service';
import { MaintenanceService } from './maintenance.service';
import { LinkVerifierService } from './link-verifier.service';
import { JobsService } from '../jobs/jobs.service';
import { CoursesService } from '../courses/courses.service';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminKeyGuard } from '../auth/admin-key.guard';
import { CustomSearchDto } from './dto/custom-search.dto';
import { UsersService } from '../users/users.service';

@ApiTags('Scraper')
@Controller('scraper')
export class ScraperController {
  constructor(
    private readonly scraperService: ScraperService,
    private readonly jobsService: JobsService,
    private readonly coursesService: CoursesService,
    private readonly maintenanceService: MaintenanceService,
    private readonly usersService: UsersService,
    private readonly linkVerifier: LinkVerifierService,
  ) {}

  // ─── POST /scraper/maintenance/expire β€” manually run job expiry/cleanup ─────
  @Post('maintenance/expire')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({ summary: 'Admin: run job expiry + cleanup now (normally daily 02:00 cron)' })
  async runExpiry() {
    await this.maintenanceService.expireAndCleanJobs();
    return { status: 'done' };
  }

  // ─── POST /scraper/maintenance/refresh β€” manually run nightly refresh ───────
  @Post('maintenance/refresh')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({ summary: 'Admin: run scheduled refresh for active users now (normally nightly 03:00 cron)' })
  async runRefresh() {
    await this.maintenanceService.refreshActiveUsers();
    return { status: 'done' };
  }

  // ─── POST /scraper/maintenance/purge-jobs β€” wipe the feed for a fresh start ──
  @Post('maintenance/purge-jobs')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({
    summary: 'Admin: delete ALL scraped jobs + link_health + job cache (fresh start)',
    description:
      'DESTRUCTIVE. Deletes every scraped_jobs row, all link_health verdicts, and the job-query ' +
      'cache so the next scrape re-fetches fresh. Preserves job_applications (saved tracker), courses, ' +
      'and scrape history. Requires ?confirm=DELETE to actually run.',
  })
  @ApiQuery({ name: 'confirm', required: false })
  async purgeJobs(@Query('confirm') confirm?: string) {
    if (confirm !== 'DELETE') {
      return {
        error: 'No-op. Pass ?confirm=DELETE to actually purge.',
        wouldDelete: 'ALL scraped_jobs + link_health + job cache',
        preserves: 'job_applications, courses, scrape history',
      };
    }
    return this.maintenanceService.purgeAllJobs();
  }

  // ─── POST /scraper/maintenance/verify-links ─────────────────────────────────
  @Post('maintenance/verify-links')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({
    summary: 'Admin: verify apply-link liveness across all currently-visible jobs',
    description:
      'Fetches every distinct apply URL of non-expired jobs, follows redirects, and records ' +
      'live/dead/unknown in link_health. Non-destructive. It never edits job rows; dead links ' +
      'are hidden by the feed JOIN and can be un-hidden by clearing link_health. Fail-open: only ' +
      'a positive death signal (404/410/removed-page) marks a link dead; blocked/timeout stays ' +
      'shown. Run from the deployed backend (its IP is not bot-blocked like a laptop/CI IP). ' +
      'Query: ?limit=N caps URLs checked; ?force=true re-checks even fresh ones.',
  })
  @ApiQuery({ name: 'limit', required: false, type: Number })
  @ApiQuery({ name: 'force', required: false, type: Boolean })
  async verifyLinks(@Query('limit') limit?: number, @Query('force') force?: string) {
    return this.maintenanceService.verifyExistingJobLinks({
      limit: limit ? Number(limit) : undefined,
      force: force === 'true' || force === '1',
    });
  }

  // ─── GET /scraper/maintenance/link-health β€” audit verdicts + evidence ───────
  @Get('maintenance/link-health')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({
    summary: 'Admin: review link-liveness verdicts with the evidence behind each',
    description:
      'The audit view for the filter. Lists link_health rows of a given status (default "dead") ' +
      'with the exact reason, HTTP status, final resolved URL, and evidence snippet, plus a ' +
      'representative job. Use it to confirm every discarded job was rightly discarded before ' +
      'trusting the pass. ?status=dead|live|unknown|pending|all, ?limit=N.',
  })
  @ApiQuery({ name: 'status', required: false, enum: ['dead', 'live', 'unknown', 'pending', 'all'] })
  @ApiQuery({ name: 'limit', required: false, type: Number })
  async linkHealth(@Query('status') status?: string, @Query('limit') limit?: number) {
    const allowed = ['dead', 'live', 'unknown', 'pending', 'all'];
    const s = (allowed.includes(status as string) ? status : 'dead') as any;
    return this.maintenanceService.listLinkHealth(s, limit ? Number(limit) : 100);
  }

  // ─── POST /scraper/maintenance/verify-url β€” re-check ONE url live (audit) ────
  @Post('maintenance/verify-url')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({
    summary: 'Admin: verify a single apply URL live and return the full verdict + evidence',
    description:
      'On-demand re-check of one URL (escalating to a browser on bot-hostile boards), for auditing ' +
      'a specific discard. Returns { status, finalUrl, httpStatus, reason, evidence }. Body: { "url": "..." }.',
  })
  async verifyUrl(@Body() body: { url?: string }) {
    if (!body?.url || !/^https?:\/\//i.test(body.url)) {
      return { error: 'Provide a valid http(s) url in the body: { "url": "..." }' };
    }
    const row = await this.linkVerifier.verifyOne(body.url, true);
    return {
      url: row.url, status: row.status, finalUrl: row.finalUrl,
      httpStatus: row.httpStatus, reason: row.reason, evidence: row.evidence,
      escalated: row.escalated, lastCheckedAt: row.lastCheckedAt,
    };
  }

  // ─── POST /scraper/maintenance/backfill-salaries ────────────────────────────
  @Post('maintenance/backfill-salaries')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({
    summary: 'Admin: fill numeric salary columns from the original strings (one-time)',
    description:
      'Jobs written before the numeric salary columns existed have NULL mirrors, and the ' +
      'salary filter requires NOT NULL, so filtering by salary returns nothing for them. ' +
      'Run once after deploying. Safe to repeat: it only touches rows still NULL.',
  })
  async backfillSalaries() {
    return this.maintenanceService.backfillSalaryValues();
  }

  // ─── POST /scraper/run ───────────────────────────────────────────────────────
  @Post('run')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({
    summary: 'Run personalized scraper for logged-in user',
    description: 'Reads user onboarding profile, generates targeted queries via AI, scrapes relevant jobs and courses, stores results in the database',
  })
  async runScraper(@Req() req: any) {
    const userId = req.user.id;
    return this.scraperService.runForUser(userId);
  }

  // ─── POST /scraper/search β€” Custom query search (async) ─────────────────────
  @Post('search')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({
    summary: 'Start a custom search (async)',
    description: 'Kicks off scraping for a user-typed query in the background and returns a jobId immediately. ' +
      'Poll GET /scraper/search/:jobId for status/result instead of blocking for 30s–5min. ' +
      'Any `filters` are pushed down into the job-board APIs, so results come back already narrowed ' +
      'rather than being filtered out of a broad pool afterwards.',
  })
  searchScraper(@Req() req: any, @Body() body: CustomSearchDto) {
    // Typing a search is deliberate intent β€” it keeps the nightly refresh alive
    // for this user. Not awaited; the search itself must not wait on bookkeeping.
    void this.usersService.markActive(req.user.id).catch(() => undefined);
    const job = this.scraperService.startCustomSearch(
      req.user.id, body.query || '', body.type || 'both', body.filters,
    );
    return { jobId: job.jobId, status: job.status, query: job.query, filters: job.filters };
  }

  // ─── GET /scraper/search/:jobId β€” poll a custom search ──────────────────────
  @Get('search/:jobId')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Poll the status/result of a custom search started via POST /scraper/search' })
  getSearchStatus(@Req() req: any, @Param('jobId') jobId: string) {
    // Scoped to the caller: another user's jobId reads as not_found.
    const job = this.scraperService.getCustomSearchStatus(jobId, req.user.id);
    if (!job) return { status: 'not_found', message: 'Unknown or expired jobId. Start a new search.' };
    return job;
  }

  // ─── POST /scraper/jobs/:jobId/resolve β€” JIT liveness check at click time ────
  @Post('jobs/:jobId/resolve')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({
    summary: 'Verify a job apply-link at click time and return where it actually lands',
    description:
      'Checks the posting live (HTTP, escalating to a browser on bot-hostile boards), follows the ' +
      'redirect chain, and returns { status, finalUrl }. The frontend opens finalUrl for live/unknown, ' +
      'or warns when dead. Persists to link_health so the verdict also removes a dead job from the feed. ' +
      'Fail-open: only a positive death signal yields status "dead".',
  })
  async resolveJobLink(@Req() req: any, @Param('jobId') jobId: string) {
    const applyUrl = await this.jobsService.getApplyUrl(req.user.id, jobId);
    if (!applyUrl) return { status: 'not_found', finalUrl: '', applyUrl: '' };
    const health = await this.linkVerifier.verifyOne(applyUrl, true);
    return {
      status: health.status,
      finalUrl: health.finalUrl || applyUrl,
      applyUrl,
      reason: health.reason,
    };
  }

  // ─── GET /scraper/runs β€” scrape history for logged-in user ──────────────────
  @Get('runs')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({
    summary: 'Get scrape run history for logged-in user',
    description: 'Shows when scrapes ran, which platforms succeeded/failed, and how many items each found',
  })
  @ApiQuery({ name: 'limit', required: false, type: Number })
  async getMyRuns(@Req() req: any, @Query('limit') limit?: number) {
    return this.scraperService.getRunHistory(req.user.id, Number(limit) || 20);
  }

  // ─── POST /scraper/migrate-legacy ────────────────────────────────────────────
  @Post('migrate-legacy')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({
    summary: 'Admin: one-time import of old per-user JSON files into the database',
    description: 'Reads src/data/users/*.json and upserts all jobs/courses into the DB. Safe to run multiple times (deduped by URL).',
  })
  async migrateLegacy() {
    return this.scraperService.migrateLegacyFiles();
  }

  // ─── POST /scraper/run/:userId ───────────────────────────────────────────────
  // Admin-only: this spends Adzuna/JSearch/Groq quota, so it must never be
  // reachable without the operator key.
  @Post('run/:userId')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({ summary: 'Admin: run scraper for a specific user by ID' })
  async runScraperForUser(@Param('userId') userId: string) {
    return this.scraperService.runForUser(userId);
  }

  // ─── GET /scraper/runs/:userId ───────────────────────────────────────────────
  @Get('runs/:userId')
  @UseGuards(AdminKeyGuard)
  @ApiSecurity('admin-key')
  @ApiOperation({ summary: 'Admin: get scrape run history for a specific user' })
  async getUserRuns(@Param('userId') userId: string, @Query('limit') limit?: number) {
    return this.scraperService.getRunHistory(userId, Number(limit) || 20);
  }

  // ─── GET /scraper/results β€” scraped results for the logged-in user ───────────
  // Was GET /scraper/results/:userId, which let any authenticated caller read
  // any other user's jobs and courses. The id now comes from the token.
  @Get('results')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Get scraped results for the logged-in user.',
    description: 'Returned fields for courses: title, instructor, platform, category, level, duration, price, rating, language, description, course_url, thumbnail, scraped_at, source. Returned fields for jobs: title, company, location, salary_min, salary_max, job_type, experience_level, category, country, source, apply_url, scraped_at' })
  async getResults(@Req() req: any) {
    const userId = req.user.id;
    const [jobs, courses] = await Promise.all([
      this.jobsService.getUserJobs(userId),
      this.coursesService.getUserCourses(userId),
    ]);

    if (jobs.length === 0 && courses.length === 0) {
      return { message: 'No results yet. Please run POST /scraper/run first.' };
    }

    return { jobs, courses };
  }
}