diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 304e78c..50c2ec0 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -4,6 +4,7 @@ abordagem aborta abra abre +abreviações Abrir absoluta Absolutas @@ -62,6 +63,7 @@ ajustar Ajustes AKVGSK aleatório +alegre além Alemanha alemania @@ -100,6 +102,8 @@ andybalholm anexar Ângela angelacao +anos +Anos Anotações anteriores antigas @@ -123,6 +127,7 @@ aplicar aplicável apontando aponte +aprendiz Apresentação apresentável apresentou @@ -140,6 +145,7 @@ asar Assinaturas assíncrona assíncrono +assistente associados ataque ataques @@ -195,6 +201,7 @@ básica Básicas bater Beene +belo Benefícios benetesla Benevanio @@ -241,6 +248,7 @@ Cancela cancelado cancelamento Cand +canddate CANDIDATADAS Candidatar candidatura @@ -259,9 +267,11 @@ carreira cascadia casos catálogo +catarina CAUSA causar causava +ceara Centraliza centralizado Centralizamos @@ -359,6 +369,7 @@ conflito conhece conhecer conhecidas +conhecido conjunto Conseguir considere @@ -391,6 +402,8 @@ Controles conversão Converte convites +cooperado +cooperativa coreia coroutinelib corpo @@ -486,6 +499,7 @@ destruir detalhada detalhadas detalhado +Detalhe detecção detecta detectadas @@ -525,6 +539,7 @@ disponível disso distinguir distribui +distrito diversas documentação documentadas @@ -546,7 +561,9 @@ ecossistema edita editadas editados +efeito efetivamente +efetivo eficiência egito elas @@ -616,6 +633,7 @@ españa espanha Espanha especiais +especialista específica especificação especificadas @@ -634,6 +652,7 @@ esqueci essa estados Estados +estagiario estágio estar Estas @@ -725,6 +744,7 @@ falhar falhas falhe falhou +falso falsos falta fazem @@ -741,6 +761,7 @@ FINALIZADO fintech flexibilidade flexíveis +florianopolis fluído flutuação fluxo @@ -760,6 +781,7 @@ fornecidos fpconv frança França +freela frequentemente fulano Fulano @@ -813,6 +835,7 @@ hintrc Histórico hoje holanda +horizonte hotjar hscan HSTS @@ -880,6 +903,7 @@ individuais individualmente inesperado inexistente +inferência inferidos infinito inflar @@ -959,6 +983,7 @@ isolar isso iterações janela +joana joaosilva jobsglobal jobsglobalscraper @@ -966,7 +991,10 @@ jobstore jooble Jooble julho +juridica KHTML +kimi +Kimi klarna Krsj kvstore @@ -994,6 +1022,7 @@ linha linhas linit listagem +listas loadlib localidade localiza @@ -1027,6 +1056,7 @@ marca marcado marcar margem +mariaclara marrocos mastigada mathlib @@ -1049,8 +1079,10 @@ memória menos mensagem mensagens +mensais mensuráveis mentores +Mentorias mescladas mesma mesmas @@ -1091,6 +1123,7 @@ monitorado Monitorar montá montar +moonshotai Mostrando Mostrar motivo @@ -1127,6 +1160,7 @@ normalizado normalizados normalizar Notas +notificação Notificações notificou novamente @@ -1149,6 +1183,7 @@ observáveis obtém obter ocasionais +ocorre omitido onde Opção @@ -1245,6 +1280,7 @@ persistir pesquisa pesquisadas Pesquisem +pessoa Pessoas piechart pior @@ -1271,6 +1307,7 @@ portas portfólio posicionamento posições +positivo positivos possível possivelmente @@ -1383,10 +1420,13 @@ recentemente recoloca Recomenda Recomendações +recomendada recomendadas recomendado recomendados +reconcilia reconecta +reconhece recorre Recrutadores recrutamento @@ -1404,6 +1444,7 @@ referência registrado registro registros +regra regras Regressão reindexação @@ -1558,6 +1599,7 @@ sinal sincronização Sincronizado Sincronizar +síncrono Singapura singleflight SISMEMBER diff --git a/backend/bruno/Auth/Login.bru b/backend/bruno/Auth/Login.bru index b86f48b..df12566 100644 --- a/backend/bruno/Auth/Login.bru +++ b/backend/bruno/Auth/Login.bru @@ -12,8 +12,8 @@ post { body:json { { - "email":"hudsontavares@gmail.com", - "password":"tavares10" + "email":"teste.criptografia.001@example.com", + "password":"SenhaForte123" } } diff --git a/backend/bruno/Jobs/SearchJobs.bru b/backend/bruno/Jobs/SearchJobs.bru index 29ef13e..5e6440d 100644 --- a/backend/bruno/Jobs/SearchJobs.bru +++ b/backend/bruno/Jobs/SearchJobs.bru @@ -5,14 +5,15 @@ meta { } get { - url: {{base_url}}/jobs/search?location=&keywords=node.js + url: {{base_url}}/jobs/search?location=Brasil&level=Junior body: none auth: inherit } params:query { - location: - keywords: node.js + location: Brasil + level: Junior + ~keywords: } headers { diff --git a/backend/drizzle/0010_profile_skill_experience_and_career_checklist.sql b/backend/drizzle/0010_profile_skill_experience_and_career_checklist.sql new file mode 100644 index 0000000..fb9b271 --- /dev/null +++ b/backend/drizzle/0010_profile_skill_experience_and_career_checklist.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "technology_experiences_encrypted" text;--> statement-breakpoint +ALTER TABLE "user_preferences" ADD COLUMN "career_checklist" jsonb DEFAULT '[]'::jsonb; diff --git a/backend/drizzle/0011_user_notifications.sql b/backend/drizzle/0011_user_notifications.sql new file mode 100644 index 0000000..c102403 --- /dev/null +++ b/backend/drizzle/0011_user_notifications.sql @@ -0,0 +1,19 @@ +CREATE TYPE "notification_channel" AS ENUM ('notification', 'message');--> statement-breakpoint +CREATE TYPE "notification_type" AS ENUM ('job_saved', 'job_applied', 'job_status_changed', 'high_match', 'mentor', 'system');--> statement-breakpoint +CREATE TABLE "user_notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "channel" "notification_channel" DEFAULT 'notification' NOT NULL, + "type" "notification_type" NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "entity_type" varchar(50), + "entity_id" text, + "metadata" jsonb DEFAULT '{}'::jsonb, + "read_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_notifications" ADD CONSTRAINT "user_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_notifications_user_created_at_idx" ON "user_notifications" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "user_notifications_user_read_at_idx" ON "user_notifications" USING btree ("user_id","read_at"); diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 2149f1a..ec7a727 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -71,6 +71,20 @@ "when": 1784121210863, "tag": "0009_encrypt_remaining_profile_fields", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1784207610863, + "tag": "0010_profile_skill_experience_and_career_checklist", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1784294010863, + "tag": "0011_user_notifications", + "breakpoints": true } ] } diff --git a/backend/src/app.ts b/backend/src/app.ts index 5551993..ff33e72 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,6 +12,7 @@ import { authRoutes } from "./routes/auth.routes"; import adminRoutes from "./routes/admin.routes"; import { jobsRoutes } from "./routes/jobs.routes"; import { keywordsRoutes } from "./routes/keywords.routes"; +import { notificationsRoutes } from "./routes/notifications.routes"; import { savedJobsRoutes } from "./routes/savedJobs.routes"; import superAdminRoutes from "./routes/superAdmin.routes"; import supportRoutes from "./routes/support.routes"; @@ -40,6 +41,7 @@ export function createJobsApiApp() { app.use("/users", withSession, requireAuth, userRoutes); app.use("/jobs", withSession, requireAuth, jobsRoutes); app.use("/keywords", withSession, requireAuth, keywordsRoutes); + app.use("/notifications", withSession, requireAuth, notificationsRoutes); app.use("/saved-jobs", withSession, requireAuth, savedJobsRoutes); app.use("/admin", withSession, supportRoutes); app.use("/admin", withSession, adminRoutes); diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 2950e00..802a982 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -4,5 +4,6 @@ export * from "./credentials"; export * from "./keywords"; export * from "./permissionRules"; export * from "./savedJobs"; +export * from "./userNotifications"; export * from "./userPreferences"; export * from "./users"; diff --git a/backend/src/db/schema/userNotifications.ts b/backend/src/db/schema/userNotifications.ts new file mode 100644 index 0000000..d48d8c3 --- /dev/null +++ b/backend/src/db/schema/userNotifications.ts @@ -0,0 +1,66 @@ +import { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { + index, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { users } from "./users"; + +export const notificationChannelEnum = pgEnum("notification_channel", [ + "notification", + "message", +]); + +export const notificationTypeEnum = pgEnum("notification_type", [ + "job_saved", + "job_applied", + "job_status_changed", + "high_match", + "mentor", + "system", +]); + +export type NotificationChannel = + (typeof notificationChannelEnum.enumValues)[number]; +export type NotificationType = (typeof notificationTypeEnum.enumValues)[number]; + +export const userNotifications = pgTable( + "user_notifications", + { + id: uuid("id").defaultRandom().primaryKey(), + + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + channel: notificationChannelEnum("channel").default("notification").notNull(), + type: notificationTypeEnum("type").notNull(), + + title: text("title").notNull(), + message: text("message").notNull(), + entityType: varchar("entity_type", { length: 50 }), + entityId: text("entity_id"), + metadata: jsonb("metadata").$type>().default({}), + + readAt: timestamp("read_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => ({ + userCreatedAtIdx: index("user_notifications_user_created_at_idx").on( + table.userId, + table.createdAt, + ), + userReadAtIdx: index("user_notifications_user_read_at_idx").on( + table.userId, + table.readAt, + ), + }), +); + +export type UserNotification = InferSelectModel; +export type NewUserNotification = InferInsertModel; diff --git a/backend/src/db/schema/userPreferences.ts b/backend/src/db/schema/userPreferences.ts index 558bdbf..da8878c 100644 --- a/backend/src/db/schema/userPreferences.ts +++ b/backend/src/db/schema/userPreferences.ts @@ -1,5 +1,6 @@ import { boolean, + jsonb, pgTable, text, timestamp, @@ -26,6 +27,16 @@ export const userPreferences = pgTable("user_preferences", { jobTypes: text("job_types").array().default([]), emailNotifications: boolean("email_notifications").default(false), + careerChecklist: jsonb("career_checklist") + .$type< + Array<{ + id: string; + title: string; + month: string; + items: Array<{ id: string; label: string; checked: boolean }>; + }> + >() + .default([]), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), diff --git a/backend/src/db/schema/users.ts b/backend/src/db/schema/users.ts index e1aaccc..cfa1bbe 100644 --- a/backend/src/db/schema/users.ts +++ b/backend/src/db/schema/users.ts @@ -48,6 +48,7 @@ export const users = pgTable( cpfHash: text("cpf_hash"), technologies: text("technologies").array().default([]), technologiesEncrypted: text("technologies_encrypted"), + technologyExperiencesEncrypted: text("technology_experiences_encrypted"), level: varchar("level", { length: 50 }), levelEncrypted: text("level_encrypted"), diff --git a/backend/src/lib/cache.ts b/backend/src/lib/cache.ts index 8466869..8b0d0be 100644 --- a/backend/src/lib/cache.ts +++ b/backend/src/lib/cache.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { createClient, type RedisClientType } from "redis"; import { logger } from "../logger"; @@ -112,18 +113,7 @@ export async function cacheSearchKeywords( ): Promise { const client = await getCache(); - const keys = keywords - .map((kw) => { - const normalized = kw - .trim() - .toLowerCase() - .replace(/\//g, " ") // troca "/" por espaço, igual ao Go - .replace(/\s+/g, " ") // colapsa múltiplos espaços em um - .trim(); - - return `scraper:jobs:keyword:${normalized}`; - }) - .filter((key) => key !== "scraper:jobs:keyword:"); // descarta keywords vazias + const keys = keywordSearchKeys(keywords); if (keys.length === 0) return []; if (keys.length === 1) return await client.sMembers(keys[0]); @@ -132,6 +122,148 @@ export async function cacheSearchKeywords( return await client.sUnion(keys); } +function normalizeIndexValue(value: string): string { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function normalizeLevelIndexValue(value: string): string { + const normalized = normalizeIndexValue(value); + if ( + normalized === "estagio trainee" || + normalized === "estagio" || + normalized === "trainee" || + normalized === "intern" || + normalized === "internship" + ) { + return "estagio"; + } + return normalized; +} + +function compactJsAlias(value: string): string { + const match = value.match(/^([a-z0-9]+)\s+js(?:\s|$)/); + return match ? `${match[1]}js` : ""; +} + +function splitJsAlias(value: string): string { + const match = value.match(/^([a-z0-9]+)js$/); + return match ? `${match[1]} js` : ""; +} + +function keywordIndexKeyVariants(keyword: string): string[] { + const legacy = keyword + .trim() + .toLowerCase() + .replace(/\//g, " ") + .replace(/\s+/g, " ") + .trim(); + const normalized = normalizeIndexValue(keyword); + const variants = new Set(); + + for (const value of [ + legacy, + normalized, + compactJsAlias(normalized), + splitJsAlias(normalized), + ]) { + if (value) variants.add(value); + } + + for (const term of normalized.split(" ")) { + if (term) variants.add(term); + } + + return [...variants].map((value) => `scraper:jobs:keyword:${value}`); +} + +function keywordSearchKeys(keywords: string[]): string[] { + return [ + ...new Set(keywords.flatMap((keyword) => keywordIndexKeyVariants(keyword))), + ].filter((key) => key !== "scraper:jobs:keyword:"); +} + +export type CacheJobIndexFilters = { + keywords?: string[]; + level?: string; + location?: string; + continent?: string; + country?: string; + state?: string; + city?: string; + type?: string; + model?: string; + contract?: string; +}; + +export function cacheJobIndexKeys(filters: CacheJobIndexFilters): string[] { + const entries: Array<[string, string | undefined]> = [ + ["level", filters.level], + ["location", filters.location], + ["continent", filters.continent], + ["country", filters.country], + ["state", filters.state], + ["city", filters.city], + ["model", filters.model ?? filters.type], + ["contract", filters.contract], + ]; + + return entries + .map(([kind, value]) => { + const normalized = + kind === "level" + ? normalizeLevelIndexValue(value ?? "") + : normalizeIndexValue(value ?? ""); + if (!normalized || normalized === "todos" || normalized === "all") { + return ""; + } + return `scraper:jobs:${kind}:${normalized}`; + }) + .filter(Boolean); +} + +export async function cacheSearchJobIds( + filters: CacheJobIndexFilters, +): Promise { + const client = await getCache(); + const keywordKeys = keywordSearchKeys(filters.keywords ?? []); + const filterKeys = cacheJobIndexKeys(filters); + + if (keywordKeys.length === 0 && filterKeys.length === 0) { + return await client.sMembers("scraper:jobs:index"); + } + + if (keywordKeys.length === 0) { + if (filterKeys.length === 1) return await client.sMembers(filterKeys[0]); + return (await client.sendCommand(["SINTER", ...filterKeys])) as string[]; + } + + if (keywordKeys.length === 1) { + const keys = [keywordKeys[0], ...filterKeys]; + if (keys.length === 1) return await client.sMembers(keys[0]); + return (await client.sendCommand(["SINTER", ...keys])) as string[]; + } + + const tempKey = `scraper:jobs:search:${randomUUID()}`; + + try { + await client.sendCommand(["SUNIONSTORE", tempKey, ...keywordKeys]); + await client.expire(tempKey, 30); + + const keys = [tempKey, ...filterKeys]; + if (keys.length === 1) return await client.sMembers(keys[0]); + return (await client.sendCommand(["SINTER", ...keys])) as string[]; + } finally { + await client.del(tempKey); + } +} + export async function cacheGetJobsByIds(ids: string[]): Promise { const client = await getCache(); @@ -152,6 +284,45 @@ export async function cacheGetJobsByIds(ids: string[]): Promise { .filter(Boolean); } +async function cacheDeleteByPattern(pattern: string): Promise { + const client = await getCache(); + let cursor = "0"; + let deleted = 0; + + do { + const result = (await client.sendCommand([ + "SCAN", + cursor, + "MATCH", + pattern, + "COUNT", + "500", + ])) as [string, string[]]; + + cursor = result[0]; + const keys = result[1] ?? []; + if (keys.length > 0) { + deleted += await client.del(keys); + } + } while (cursor !== "0"); + + return deleted; +} + +export async function cacheClearJobs(): Promise<{ + deleted: number; + patterns: string[]; +}> { + const patterns = ["scraper:job:*", "scraper:jobs:*"]; + let deleted = 0; + + for (const pattern of patterns) { + deleted += await cacheDeleteByPattern(pattern); + } + + return { deleted, patterns }; +} + export async function cachePing(): Promise { const client = await getCache(); return await client.ping(); diff --git a/backend/src/modules/admin/scrapers/scraperClient.ts b/backend/src/modules/admin/scrapers/scraperClient.ts index af33809..69caf52 100644 --- a/backend/src/modules/admin/scrapers/scraperClient.ts +++ b/backend/src/modules/admin/scrapers/scraperClient.ts @@ -44,8 +44,9 @@ export const scraperClient = { return request("/admin/scrape/status"); }, - getJobs(): Promise { - return request("/admin/jobs"); + getJobs(limit?: number): Promise { + const suffix = limit && limit > 0 ? `?limit=${limit}` : ""; + return request(`/admin/jobs${suffix}`); }, getJobsCount(): Promise { diff --git a/backend/src/modules/admin/scrapers/scrapers.controller.ts b/backend/src/modules/admin/scrapers/scrapers.controller.ts index 5834e77..e78c655 100644 --- a/backend/src/modules/admin/scrapers/scrapers.controller.ts +++ b/backend/src/modules/admin/scrapers/scrapers.controller.ts @@ -57,7 +57,9 @@ export class ScrapersController { async listJobs(req: Request, res: Response): Promise { try { - const result = await this.scrapersService.getJobs(); + const rawLimit = Number(req.query?.limit); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : undefined; + const result = await this.scrapersService.getJobs(limit); this.auditService.fromRequest(req, "scrapers.read", { type: "scrapers", diff --git a/backend/src/modules/admin/scrapers/scrapers.service.ts b/backend/src/modules/admin/scrapers/scrapers.service.ts index c9735fb..d37d35f 100644 --- a/backend/src/modules/admin/scrapers/scrapers.service.ts +++ b/backend/src/modules/admin/scrapers/scrapers.service.ts @@ -40,8 +40,8 @@ export class ScrapersService { ]; } - async getJobs(): Promise { - return scraperClient.getJobs(); + async getJobs(limit?: number): Promise { + return scraperClient.getJobs(limit); } async getJobsCount(): Promise { diff --git a/backend/src/modules/jobs/jobMatch.service.ts b/backend/src/modules/jobs/jobMatch.service.ts new file mode 100644 index 0000000..0b2d760 --- /dev/null +++ b/backend/src/modules/jobs/jobMatch.service.ts @@ -0,0 +1,158 @@ +import { SavedJob, User } from "../../db/schema"; +import { toPublicUser } from "../users/users.mapper"; + +type TechnologyExperience = { + name: string; + years: number; +}; + +export type MatchableJob = { + id?: string | null; + title?: string | null; + jobTitle?: string | null; + company?: string | null; + location?: string | null; + modality?: string | null; + type?: string | null; + level?: string | null; + keyword?: string | null; + keywords?: string[] | null; + description?: string | null; + url?: string | null; + [key: string]: unknown; +}; + +export type MatchedJob = MatchableJob & { + matchScore?: number; + matchSource?: "backend_profile"; + matchedTechnologies?: string[]; +}; + +function normalizeMatchText(value: string) { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function matchAliases(technology: string) { + const normalized = normalizeMatchText(technology); + const aliases = new Set([normalized]); + + if (normalized.endsWith(" js")) { + aliases.add(normalized.replace(/\s+js$/, "js")); + } + if (normalized.endsWith("js") && normalized.length > 2) { + aliases.add(normalized.replace(/js$/, " js")); + } + + return [...aliases].filter(Boolean); +} + +function textMatchesAlias(text: string, alias: string) { + if (!alias) return false; + if (alias.includes(" ")) return text.includes(alias); + return ` ${text} `.includes(` ${alias} `); +} + +function jobMatchText(job: MatchableJob) { + const rawValues = Object.values(job).flatMap((value) => + Array.isArray(value) ? value : [value], + ); + + return normalizeMatchText( + rawValues.map((value) => (typeof value === "string" ? value : "")).join(" "), + ); +} + +function parseTechnologiesFromUser(user: User): TechnologyExperience[] { + const publicUser = toPublicUser(user); + const experiences = publicUser.technologyExperiences; + + if (Array.isArray(experiences)) { + return experiences + .map((item) => { + if (!item || typeof item !== "object") return null; + const data = item as Record; + const name = typeof data.name === "string" ? data.name.trim() : ""; + const years = typeof data.years === "number" ? data.years : 1; + return name ? { name, years: Math.max(0, years) } : null; + }) + .filter((item): item is TechnologyExperience => Boolean(item)); + } + + return (publicUser.technologies ?? []) + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => ({ name, years: 1 })); +} + +export function getUserMatchTechnologies(user: User | undefined | null) { + if (!user) return []; + return parseTechnologiesFromUser(user); +} + +export function scoreJobWithTechnologies( + job: MatchableJob, + technologies: TechnologyExperience[], +): MatchedJob { + const normalizedTechnologies = [ + ...new Map( + technologies + .filter((technology) => technology.name.trim()) + .map((technology) => [ + normalizeMatchText(technology.name), + { + name: technology.name.trim(), + years: Math.max(0, technology.years), + }, + ]), + ).values(), + ]; + + if (normalizedTechnologies.length === 0) return job; + + const text = jobMatchText(job); + const matchedTechnologies = normalizedTechnologies.filter((technology) => + matchAliases(technology.name).some((alias) => textMatchesAlias(text, alias)), + ); + + const totalWeight = normalizedTechnologies.reduce( + (total, technology) => total + Math.max(1, technology.years), + 0, + ); + const matchedWeight = matchedTechnologies.reduce( + (total, technology) => total + Math.max(1, technology.years), + 0, + ); + const coverage = matchedWeight / totalWeight; + const score = + matchedTechnologies.length === 0 + ? 45 + : Math.min( + 99, + 55 + + Math.round(coverage * 35) + + Math.min(matchedTechnologies.length * 4, 9), + ); + + return { + ...job, + matchScore: score, + matchSource: "backend_profile", + matchedTechnologies: matchedTechnologies.map((item) => item.name), + }; +} + +export function jobNotificationIdentity(job: MatchableJob | SavedJob) { + const url = + "url" in job && typeof job.url === "string" + ? job.url + : "jobLink" in job && typeof job.jobLink === "string" + ? job.jobLink + : ""; + return url.trim() || String(job.id ?? ""); +} diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..3953538 --- /dev/null +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,39 @@ +import { Request, Response } from "express"; +import { AppError } from "../../lib/errors"; +import { NotificationsService } from "./notifications.service"; + +export class NotificationsController { + constructor(private readonly service: NotificationsService) {} + + private requireUserId(req: Request): string { + const userId = req.session?.userId; + if (!userId) { + throw AppError.unauthorized(); + } + return userId; + } + + async list(req: Request, res: Response) { + const userId = this.requireUserId(req); + const result = await this.service.list(userId, req.query as any); + return res.json(result); + } + + async markRead(req: Request, res: Response) { + const userId = this.requireUserId(req); + const notification = await this.service.markRead( + userId, + req.params.id as string, + ); + return res.json(notification); + } + + async markAllRead(req: Request, res: Response) { + const userId = this.requireUserId(req); + const result = await this.service.markAllRead( + userId, + (req.query as { channel?: "notification" | "message" }).channel, + ); + return res.json(result); + } +} diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..87d96f6 --- /dev/null +++ b/backend/src/modules/notifications/notifications.service.ts @@ -0,0 +1,200 @@ +import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { db } from "../../db/client"; +import { NewUserNotification, SavedJob, userNotifications } from "../../db/schema"; +import { DB } from "../../db/types/types"; +import { AppError } from "../../lib/errors"; +import { + jobNotificationIdentity, + MatchedJob, +} from "../jobs/jobMatch.service"; +import { ListNotificationsQuery } from "./schemas/notifications.schemas"; + +const statusLabels: Record = { + saved: "Salva", + applied: "Candidatura enviada", + interviewing: "Em entrevista", + rejected: "Não selecionada", + accepted: "Aprovada", +}; + +function jobName(job: Pick) { + const title = job.jobTitle?.trim() || "vaga"; + const company = job.company?.trim(); + return company ? `${title} na ${company}` : title; +} + +export class NotificationsService { + constructor(private readonly tx: DB = db) {} + + async list(userId: string, filters: ListNotificationsQuery) { + const conditions = [eq(userNotifications.userId, userId)]; + + if (filters.channel) { + conditions.push(eq(userNotifications.channel, filters.channel)); + } + + if (filters.unreadOnly) { + conditions.push(isNull(userNotifications.readAt)); + } + + const where = and(...conditions); + const [items, unreadRows] = await Promise.all([ + this.tx + .select() + .from(userNotifications) + .where(where) + .orderBy(desc(userNotifications.createdAt)) + .limit(filters.limit), + this.tx + .select({ value: count() }) + .from(userNotifications) + .where( + and( + eq(userNotifications.userId, userId), + filters.channel + ? eq(userNotifications.channel, filters.channel) + : undefined, + isNull(userNotifications.readAt), + ), + ), + ]); + + return { + notifications: items, + unreadCount: Number(unreadRows[0]?.value ?? 0), + }; + } + + async create(data: NewUserNotification) { + const result = await this.tx.insert(userNotifications).values(data).returning(); + return result[0]; + } + + async markRead(userId: string, notificationId: string) { + const result = await this.tx + .update(userNotifications) + .set({ readAt: new Date() }) + .where( + and( + eq(userNotifications.id, notificationId), + eq(userNotifications.userId, userId), + ), + ) + .returning(); + + if (!result[0]) { + throw AppError.notFound("Notificação não encontrada"); + } + + return result[0]; + } + + async markAllRead(userId: string, channel?: "notification" | "message") { + const result = await this.tx + .update(userNotifications) + .set({ readAt: new Date() }) + .where( + and( + eq(userNotifications.userId, userId), + channel ? eq(userNotifications.channel, channel) : undefined, + isNull(userNotifications.readAt), + ), + ) + .returning({ id: userNotifications.id }); + + return { updated: result.length }; + } + + async createForSavedJob(userId: string, job: SavedJob) { + const type = job.status === "applied" ? "job_applied" : "job_saved"; + const title = + job.status === "applied" ? "Candidatura enviada" : "Vaga salva"; + const message = + job.status === "applied" + ? `Sua candidatura para ${jobName(job)} foi registrada.` + : `${jobName(job)} foi adicionada às suas vagas salvas.`; + + return this.create({ + userId, + channel: "notification", + type, + title, + message, + entityType: "job", + entityId: job.id, + metadata: { + jobLink: job.jobLink, + jobTitle: job.jobTitle, + company: job.company, + status: job.status, + }, + }); + } + + async createForJobStatusChange( + userId: string, + previous: SavedJob, + next: SavedJob, + ) { + if (previous.status === next.status) return null; + + return this.create({ + userId, + channel: "notification", + type: next.status === "applied" ? "job_applied" : "job_status_changed", + title: "Status de candidatura atualizado", + message: `${jobName(next)} agora está em "${ + statusLabels[next.status] ?? next.status + }".`, + entityType: "job", + entityId: next.id, + metadata: { + jobLink: next.jobLink, + jobTitle: next.jobTitle, + company: next.company, + previousStatus: previous.status, + status: next.status, + }, + }); + } + + async createHighMatchIfMissing(userId: string, job: MatchedJob) { + if ((job.matchScore ?? 0) < 85) return null; + + const entityId = jobNotificationIdentity(job); + if (!entityId) return null; + + const existing = await this.tx.query.userNotifications.findFirst({ + where: (notification, { and, eq }) => + and( + eq(notification.userId, userId), + eq(notification.type, "high_match"), + eq(notification.entityType, "job"), + eq(notification.entityId, entityId), + ), + }); + + if (existing) return existing; + + const title = job.title?.trim() || job.jobTitle?.trim() || "vaga"; + const company = job.company?.trim(); + const namedJob = company ? `${title} na ${company}` : title; + + return this.create({ + userId, + channel: "notification", + type: "high_match", + title: "Vaga com alto match encontrada", + message: `${namedJob} tem ${job.matchScore}% de compatibilidade com o seu perfil.`, + entityType: "job", + entityId, + metadata: { + jobLink: entityId, + jobTitle: title, + company: job.company, + matchScore: job.matchScore, + matchedTechnologies: job.matchedTechnologies ?? [], + }, + }); + } +} diff --git a/backend/src/modules/notifications/schemas/notifications.schemas.ts b/backend/src/modules/notifications/schemas/notifications.schemas.ts new file mode 100644 index 0000000..9d1cca5 --- /dev/null +++ b/backend/src/modules/notifications/schemas/notifications.schemas.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const listNotificationsQuerySchema = z.object({ + channel: z.enum(["notification", "message"]).optional(), + unreadOnly: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .optional(), + limit: z.coerce.number().int().min(1).max(100).default(30), +}); + +export type ListNotificationsQuery = z.infer< + typeof listNotificationsQuerySchema +>; + +export const markAllNotificationsReadQuerySchema = z.object({ + channel: z.enum(["notification", "message"]).optional(), +}); diff --git a/backend/src/modules/savedJobs/savedJobs.service.ts b/backend/src/modules/savedJobs/savedJobs.service.ts index 96bd261..bbd27d9 100644 --- a/backend/src/modules/savedJobs/savedJobs.service.ts +++ b/backend/src/modules/savedJobs/savedJobs.service.ts @@ -3,6 +3,7 @@ import { db } from "../../db/client"; import { NewSavedJob, SavedJob, savedJobs } from "../../db/schema"; import { DB } from "../../db/types/types"; import { AppError } from "../../lib/errors"; +import { NotificationsService } from "../notifications/notifications.service"; export class SavedJobsService { constructor(private readonly tx: DB = db) {} @@ -37,6 +38,7 @@ export class SavedJobsService { .insert(savedJobs) .values({ ...data, userId }) .returning(); + await new NotificationsService(this.tx).createForSavedJob(userId, result[0]); return result[0]; } @@ -45,6 +47,11 @@ export class SavedJobsService { jobId: string, data: Partial, ): Promise { + const previous = await this.getById(userId, jobId); + if (!previous) { + throw AppError.notFound("Vaga não encontrada"); + } + const result = await this.tx .update(savedJobs) .set({ ...data, updatedAt: new Date() }) @@ -54,6 +61,11 @@ export class SavedJobsService { if (!result[0]) { throw AppError.notFound("Vaga não encontrada"); } + await new NotificationsService(this.tx).createForJobStatusChange( + userId, + previous, + result[0], + ); return result[0]; } diff --git a/backend/src/modules/types/user.types.ts b/backend/src/modules/types/user.types.ts index d9672b6..eb508ba 100644 --- a/backend/src/modules/types/user.types.ts +++ b/backend/src/modules/types/user.types.ts @@ -17,6 +17,9 @@ export type UpdateProfileData = Partial< | "phone" | "cpf" | "technologies" + | "technologyExperiencesEncrypted" | "level" > ->; +> & { + technologyExperiences?: Array<{ name: string; years: number }> | null; +}; diff --git a/backend/src/modules/users/functions/createUser.ts b/backend/src/modules/users/functions/createUser.ts index 40ad78f..08dfadb 100644 --- a/backend/src/modules/users/functions/createUser.ts +++ b/backend/src/modules/users/functions/createUser.ts @@ -14,6 +14,7 @@ export type CreateUserParams = { phone?: string | null; cpf?: string | null; technologies?: string[] | null; + technologyExperiences?: Array<{ name: string; years: number }> | null; level?: string | null; }; diff --git a/backend/src/modules/users/schemas/user.schemas.ts b/backend/src/modules/users/schemas/user.schemas.ts index 6491bbf..b680144 100644 --- a/backend/src/modules/users/schemas/user.schemas.ts +++ b/backend/src/modules/users/schemas/user.schemas.ts @@ -24,6 +24,14 @@ export const updateProfileSchema = z .regex(/^\d{3}\.?\d{3}\.?\d{3}-?\d{2}$/, "CPF inválido") .nullable(), technologies: z.array(z.string().min(1)).max(30), + technologyExperiences: z + .array( + z.object({ + name: z.string().min(1).max(60), + years: z.number().min(0).max(50), + }), + ) + .max(30), level: z.string().max(50).nullable(), }) .partial(); @@ -38,6 +46,24 @@ export const updatePreferencesSchema = z remoteOnly: z.boolean(), jobTypes: z.array(z.string().min(1)).max(10), emailNotifications: z.boolean(), + careerChecklist: z + .array( + z.object({ + id: z.string().min(1), + title: z.string().min(1).max(120), + month: z.string().regex(/^\d{4}-\d{2}$/), + items: z + .array( + z.object({ + id: z.string().min(1), + label: z.string().min(1).max(200), + checked: z.boolean(), + }), + ) + .max(100), + }), + ) + .max(36), }) .partial(); diff --git a/backend/src/modules/users/users.mapper.ts b/backend/src/modules/users/users.mapper.ts index 510e6c1..486b249 100644 --- a/backend/src/modules/users/users.mapper.ts +++ b/backend/src/modules/users/users.mapper.ts @@ -12,7 +12,11 @@ function protectTechnologies(value: string[] | null | undefined) { return value ? protectNullableText(JSON.stringify(value)) : null; } -function parseTechnologies(value: string | null | undefined) { +function protectJson(value: unknown[] | null | undefined) { + return value ? protectNullableText(JSON.stringify(value)) : null; +} + +function parseEncryptedArray(value: string | null | undefined) { if (!value) return null; try { @@ -41,6 +45,9 @@ export function toUserCreateValues(profile: CreateUserParams): Partial ...protectCpf(profile.cpf), technologies: null, technologiesEncrypted: protectTechnologies(profile.technologies ?? []), + technologyExperiencesEncrypted: protectJson( + profile.technologyExperiences ?? [], + ), level: null, levelEncrypted: protectNullableText(profile.level), }; @@ -84,6 +91,13 @@ export function toUserUpdateValues(data: UpdateProfileData): Partial { values.technologiesEncrypted = protectTechnologies(data.technologies); } + if ("technologyExperiences" in data) { + values.technologyExperiencesEncrypted = protectJson( + data.technologyExperiences, + ); + delete (values as Record).technologyExperiences; + } + if ("level" in data) { values.level = null; values.levelEncrypted = protectNullableText(data.level); @@ -92,7 +106,9 @@ export function toUserUpdateValues(data: UpdateProfileData): Partial { return values; } -export function toPublicUser(user: User): User { +export function toPublicUser( + user: User, +): User & { technologyExperiences?: unknown[] | null } { return { ...user, email: user.emailEncrypted ? decryptText(user.emailEncrypted) : user.email, @@ -111,7 +127,10 @@ export function toPublicUser(user: User): User { phone: user.phoneEncrypted ? decryptText(user.phoneEncrypted) : user.phone, cpf: user.cpfEncrypted ? decryptText(user.cpfEncrypted) : user.cpf, technologies: - parseTechnologies(user.technologiesEncrypted) ?? user.technologies, + parseEncryptedArray(user.technologiesEncrypted) ?? user.technologies, + technologyExperiences: parseEncryptedArray( + user.technologyExperiencesEncrypted, + ), level: user.levelEncrypted ? decryptText(user.levelEncrypted) : user.level, }; } diff --git a/backend/src/routes/jobs.routes.ts b/backend/src/routes/jobs.routes.ts index 4989050..1830753 100644 --- a/backend/src/routes/jobs.routes.ts +++ b/backend/src/routes/jobs.routes.ts @@ -2,10 +2,18 @@ import { Request, Response, Router } from "express"; import { cacheAbsoluteSMembers, cacheGetJobsByIds, + cacheSearchJobIds, cacheSearchKeywords, } from "../lib/cache"; import { paginate, parsePagination } from "../lib/pagination"; import { logWarn } from "../logger"; +import { + getUserMatchTechnologies, + MatchableJob, + scoreJobWithTechnologies, +} from "../modules/jobs/jobMatch.service"; +import { NotificationsService } from "../modules/notifications/notifications.service"; +import { UsersService } from "../modules/users/users.service"; export const jobsRoutes = Router(); @@ -16,6 +24,11 @@ type SearchJob = { description?: string | null; }; +type MatchTechnology = { + name: string; + years: number; +}; + function firstQueryValue(value: unknown): string { if (Array.isArray(value)) return firstQueryValue(value[0]); return typeof value === "string" ? value.trim() : ""; @@ -26,15 +39,63 @@ function normalizeComparable(value: string): string { .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") .trim(); } +function normalizeLevelFilter(value: string): string { + const normalized = normalizeComparable(value); + if ( + normalized === "estagio trainee" || + normalized === "estagio" || + normalized === "trainee" || + normalized === "intern" || + normalized === "internship" + ) { + return "estagio"; + } + return normalized; +} + +function containsTokenOrPhrase(text: string, needle: string): boolean { + if (needle.includes(" ")) return text.includes(needle); + return ` ${text} `.includes(` ${needle} `); +} + +function containsAny(text: string, needles: string[]): boolean { + return needles.some((needle) => containsTokenOrPhrase(text, needle)); +} + function inferJobLevel(title: string): string { const normalized = normalizeComparable(title); - if (normalized.includes("senior") || normalized.includes("sr")) { + if ( + containsAny(normalized, [ + "estagio", + "estagiario", + "intern", + "internship", + "trainee", + "aprendiz", + ]) + ) { + return "estagio"; + } + if ( + containsAny(normalized, [ + "senior", + "sr", + "especialista", + "lead", + "principal", + "staff", + ]) + ) { return "senior"; } - if (normalized.includes("junior") || normalized.includes("jr")) { + if ( + containsAny(normalized, ["junior", "jr", "entry level", "assistente"]) + ) { return "junior"; } return "pleno"; @@ -78,10 +139,83 @@ function inferJobType(job: SearchJob): string { return "presencial"; } +function inferLocationCountry(location: string): string { + const normalized = normalizeComparable(location); + if (!normalized) return ""; + + if ( + containsAny(normalized, [ + "estados unidos", + "united states", + "usa", + "eua", + "florida", + "miami", + "new york", + "california", + "texas", + "boston", + "seattle", + "chicago", + "atlanta", + "denver", + ]) + ) { + return "estados unidos"; + } + + if ( + containsAny(normalized, [ + "brasil", + "brazil", + "sao paulo", + "rio de janeiro", + "minas gerais", + "belo horizonte", + "parana", + "curitiba", + "santa catarina", + "joinville", + "rio grande do sul", + "porto alegre", + "pernambuco", + "recife", + "bahia", + "salvador", + "ceara", + "fortaleza", + "piaui", + "teresina", + ]) + ) { + return "brasil"; + } + + if (containsAny(normalized, ["portugal", "lisboa", "porto"])) { + return "portugal"; + } + + return ""; +} + +function matchesLocationFilter(jobLocation: string, location: string): boolean { + if (!location) return true; + + const normalizedLocation = normalizeComparable(jobLocation); + const inferredCountry = inferLocationCountry(jobLocation); + if (inferredCountry) return inferredCountry === location; + + return normalizedLocation.includes(location); +} + function filterJobs(jobs: unknown[], query: Request["query"]): unknown[] { - const level = normalizeComparable(firstQueryValue(query.level)); - const location = normalizeComparable(firstQueryValue(query.location)); - const type = normalizeComparable(firstQueryValue(query.type)); + const level = normalizeLevelFilter(firstQueryValue(query.level)); + const location = normalizeComparable( + firstQueryValue(query.country) || firstQueryValue(query.location), + ); + const type = normalizeComparable( + firstQueryValue(query.model) || firstQueryValue(query.type), + ); if (!level && !location && !type) return jobs; @@ -89,23 +223,102 @@ function filterJobs(jobs: unknown[], query: Request["query"]): unknown[] { const candidate = job as SearchJob; const title = candidate.title ?? ""; const jobLocation = candidate.location ?? ""; - const normalizedLocation = normalizeComparable(jobLocation); const matchesLevel = !level || inferJobLevel(title) === level; - const matchesLocation = - !location || normalizedLocation.includes(location); + const matchesLocation = matchesLocationFilter(jobLocation, location); const matchesType = !type || inferJobType(candidate) === type; return matchesLevel && matchesLocation && matchesType; }); } -function hasPostFetchFilters(query: Request["query"]): boolean { +function hasStructuredFilters(query: Request["query"]): boolean { return Boolean( firstQueryValue(query.level) || firstQueryValue(query.location) || - firstQueryValue(query.type), + firstQueryValue(query.country) || + firstQueryValue(query.continent) || + firstQueryValue(query.state) || + firstQueryValue(query.city) || + firstQueryValue(query.type) || + firstQueryValue(query.model) || + firstQueryValue(query.contract) || + firstQueryValue(query.contractType) || + firstQueryValue(query.jobTypes), + ); +} + +function getKeywordsArray(query: Request["query"]): string[] { + const keywords = firstQueryValue(query.keywords); + if (!keywords) return []; + + return keywords + .split(",") + .map((k) => k.trim()) + .filter(Boolean); +} + +async function legacyResolveIds( + keywordsArray: string[], +): Promise<{ ids: string[]; source: string }> { + if (keywordsArray.length > 0) { + return { + ids: await cacheSearchKeywords(keywordsArray), + source: `valkey_filtered_by_keywords:${keywordsArray.join("+")}`, + }; + } + + return { + ids: await cacheAbsoluteSMembers("scraper:jobs:index"), + source: "valkey_global_index", + }; +} + +async function getCurrentUserMatchTechnologies(req: Request) { + const userId = req.session?.userId; + if (!userId) return []; + + try { + const user = await new UsersService().getUserById(userId); + return getUserMatchTechnologies(user); + } catch (error) { + logWarn("Não foi possível carregar perfil para cálculo de match", { + error: (error as Error).message, + userId, + }); + return []; + } +} + +async function enrichJobsWithProfileMatch( + req: Request, + jobs: unknown[], + technologies: MatchTechnology[], +) { + if (technologies.length === 0) return jobs; + + const matchedJobs = jobs.map((job) => + scoreJobWithTechnologies(job as MatchableJob, technologies), ); + const userId = req.session?.userId; + if (!userId) return matchedJobs; + + const notifications = new NotificationsService(); + await Promise.all( + matchedJobs + .filter((job) => (job.matchScore ?? 0) >= 85) + .map((job) => + notifications.createHighMatchIfMissing(userId, job).catch((error) => { + logWarn("Não foi possível registrar notificação de alto match", { + error: (error as Error).message, + userId, + job: job.title ?? job.jobTitle ?? job.id, + }); + }), + ), + ); + + return matchedJobs; } /** @@ -123,27 +336,72 @@ function hasPostFetchFilters(query: Request["query"]): boolean { */ jobsRoutes.get("/search", async (req: Request, res: Response) => { try { - const { keywords } = req.query; + const keywordsArray = getKeywordsArray(req.query); const pagination = parsePagination(req.query); + const hasFilters = hasStructuredFilters(req.query); + const matchTechnologies = await getCurrentUserMatchTechnologies(req); let ids: string[] = []; - let source = "valkey_global_index"; + let source = + keywordsArray.length > 0 + ? `valkey_filtered_by_keywords:${keywordsArray.join("+")}` + : "valkey_global_index"; + + if (hasFilters) { + ids = await cacheSearchJobIds({ + keywords: keywordsArray, + level: firstQueryValue(req.query.level), + location: firstQueryValue(req.query.location), + continent: firstQueryValue(req.query.continent), + country: firstQueryValue(req.query.country), + state: firstQueryValue(req.query.state), + city: firstQueryValue(req.query.city), + type: firstQueryValue(req.query.type), + model: firstQueryValue(req.query.model), + contract: + firstQueryValue(req.query.contract) || + firstQueryValue(req.query.contractType) || + firstQueryValue(req.query.jobTypes), + }); + source = `${source}:structured_indexes`; - if (keywords) { - const keywordsArray = String(keywords) - .split(",") - .map((k) => k.trim()) - .filter(Boolean); + if (ids.length === 0) { + const legacy = await legacyResolveIds(keywordsArray); + const legacyJobs = await cacheGetJobsByIds(legacy.ids); + const filteredJobs = filterJobs(legacyJobs, req.query); + const { data: pageJobs, pagination: meta } = paginate( + filteredJobs, + pagination, + ); + const jobs = await enrichJobsWithProfileMatch( + req, + pageJobs, + matchTechnologies, + ); - ids = await cacheSearchKeywords(keywordsArray); - source = `valkey_filtered_by_keywords:${keywordsArray.join("+")}`; - } else { - ids = await cacheAbsoluteSMembers("scraper:jobs:index"); - } + return res.json({ + total: meta.total, + page: meta.page, + limit: meta.limit, + totalPages: meta.totalPages, + hasNext: meta.hasNext, + hasPrev: meta.hasPrev, + jobs, + source: `${source}:legacy_post_filter_fallback`, + }); + } - if (!hasPostFetchFilters(req.query)) { - const { data: pageIds, pagination: meta } = paginate(ids, pagination); - const jobs = await cacheGetJobsByIds(pageIds); + const indexedJobs = await cacheGetJobsByIds(ids); + const filteredJobs = filterJobs(indexedJobs, req.query); + const { data: pageJobs, pagination: meta } = paginate( + filteredJobs, + pagination, + ); + const jobs = await enrichJobsWithProfileMatch( + req, + pageJobs, + matchTechnologies, + ); return res.json({ total: meta.total, @@ -153,13 +411,21 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => { hasNext: meta.hasNext, hasPrev: meta.hasPrev, jobs, - source, + source: `${source}:verified`, }); + } else { + const legacy = await legacyResolveIds(keywordsArray); + ids = legacy.ids; + source = legacy.source; } - const allJobs = await cacheGetJobsByIds(ids); - const filteredJobs = filterJobs(allJobs, req.query); - const { data: jobs, pagination: meta } = paginate(filteredJobs, pagination); + const { data: pageIds, pagination: meta } = paginate(ids, pagination); + const pageJobs = await cacheGetJobsByIds(pageIds); + const jobs = await enrichJobsWithProfileMatch( + req, + pageJobs, + matchTechnologies, + ); return res.json({ total: meta.total, diff --git a/backend/src/routes/notifications.routes.ts b/backend/src/routes/notifications.routes.ts new file mode 100644 index 0000000..17f86af --- /dev/null +++ b/backend/src/routes/notifications.routes.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import { validate } from "../middleware/validate"; +import { NotificationsController } from "../modules/notifications/notifications.controller"; +import { NotificationsService } from "../modules/notifications/notifications.service"; +import { + listNotificationsQuerySchema, + markAllNotificationsReadQuerySchema, +} from "../modules/notifications/schemas/notifications.schemas"; + +const router = Router(); +const service = new NotificationsService(); +const controller = new NotificationsController(service); + +router.get( + "/", + validate({ query: listNotificationsQuerySchema }), + (req, res, next) => { + controller.list(req, res).catch(next); + }, +); + +router.patch( + "/read-all", + validate({ query: markAllNotificationsReadQuerySchema }), + (req, res, next) => { + controller.markAllRead(req, res).catch(next); + }, +); + +router.patch("/:id/read", (req, res, next) => { + controller.markRead(req, res).catch(next); +}); + +export { router as notificationsRoutes }; diff --git a/backend/src/routes/superAdmin.routes.ts b/backend/src/routes/superAdmin.routes.ts index 8d77b25..c261cbc 100644 --- a/backend/src/routes/superAdmin.routes.ts +++ b/backend/src/routes/superAdmin.routes.ts @@ -1,9 +1,11 @@ import { Router } from "express"; +import { cacheClearJobs } from "../lib/cache"; import { requireAuth } from "../middleware/requireAuth"; import { requirePermission, requireRole, } from "../modules/admin/permissions/requireRole"; +import { logWarn } from "../logger"; import { permissionsCtrl, usersCtrl } from "./admin.context"; const router = Router(); @@ -27,4 +29,26 @@ router.patch( permissionsCtrl.updateRules.bind(permissionsCtrl), ); +router.delete("/jobs/cache", async (_req, res) => { + try { + const result = await cacheClearJobs(); + + return res.json({ + ok: true, + deleted: result.deleted, + patterns: result.patterns, + }); + } catch (error) { + logWarn("Erro ao limpar cache de vagas no Valkey", { + error: (error as Error).message, + }); + + return res.status(500).json({ + ok: false, + message: "Erro ao limpar cache de vagas.", + error: (error as Error).message, + }); + } +}); + export default router; diff --git a/backend/tests/integration/routes/admin.routes.test.ts b/backend/tests/integration/routes/admin.routes.test.ts index 089fa38..d3d75ef 100644 --- a/backend/tests/integration/routes/admin.routes.test.ts +++ b/backend/tests/integration/routes/admin.routes.test.ts @@ -48,6 +48,7 @@ const mocks = vi.hoisted(() => ({ audit: vi.fn((_req, res) => res.json({ data: [], total: 0 })), permissionsRules: vi.fn((_req, res) => res.json({ rules: [] })), permissionsUpdateRules: vi.fn((_req, res) => res.json({ ok: true })), + cacheClearJobs: vi.fn(), })); vi.mock("../../../src/routes/admin.context", () => ({ @@ -95,6 +96,15 @@ vi.mock("../../../src/modules/admin/permissions/permissions.service", () => ({ }, })); +vi.mock("../../../src/lib/cache", () => ({ + cacheAbsoluteSMembers: vi.fn(), + cacheClearJobs: mocks.cacheClearJobs, + cacheGetJobsByIds: vi.fn(), + cacheSearchJobIds: vi.fn(), + cacheSearchKeywords: vi.fn(), + getCache: vi.fn(), +})); + import { getIronSession } from "iron-session"; import { createJobsApiApp } from "../../../src/app"; @@ -112,6 +122,10 @@ describe("Integration - Admin Routes", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.cacheClearJobs.mockResolvedValue({ + deleted: 4, + patterns: ["scraper:job:*", "scraper:jobs:*"], + }); vi.mocked(getIronSession).mockResolvedValue(session("support") as any); app = createJobsApiApp(); }); @@ -170,6 +184,27 @@ describe("Integration - Admin Routes", () => { expect(mocks.deleteUser).toHaveBeenCalled(); }); + it("super_admin limpa cache de vagas do Valkey", async () => { + vi.mocked(getIronSession).mockResolvedValue(session("super_admin") as any); + + const res = await request(app).delete("/admin/jobs/cache").expect(200); + + expect(mocks.cacheClearJobs).toHaveBeenCalled(); + expect(res.body).toEqual({ + ok: true, + deleted: 4, + patterns: ["scraper:job:*", "scraper:jobs:*"], + }); + }); + + it("admin não limpa cache de vagas do Valkey", async () => { + vi.mocked(getIronSession).mockResolvedValue(session("admin") as any); + + await request(app).delete("/admin/jobs/cache").expect(403); + + expect(mocks.cacheClearJobs).not.toHaveBeenCalled(); + }); + it("usuário comum não acessa rotas administrativas", async () => { vi.mocked(getIronSession).mockResolvedValue(session("user") as any); diff --git a/backend/tests/integration/routes/notifications.routes.test.ts b/backend/tests/integration/routes/notifications.routes.test.ts new file mode 100644 index 0000000..1121d52 --- /dev/null +++ b/backend/tests/integration/routes/notifications.routes.test.ts @@ -0,0 +1,126 @@ +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../src/lib/errors"; + +const mockNotificationsService = vi.hoisted(() => ({ + list: vi.fn(), + markRead: vi.fn(), + markAllRead: vi.fn(), +})); + +vi.mock("../../../src/modules/notifications/notifications.service", () => ({ + NotificationsService: class { + constructor() { + return mockNotificationsService; + } + }, +})); + +vi.mock("iron-session", () => ({ + getIronSession: vi.fn(), +})); + +import { getIronSession } from "iron-session"; +import { createJobsApiApp } from "../../../src/app"; + +const fixtureSession = { + userId: "user_abc", + save: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), +}; + +const fixtureNotification = { + id: "notification-1", + userId: "user_abc", + channel: "notification", + type: "job_saved", + title: "Vaga salva", + message: "Frontend Developer foi adicionada às suas vagas salvas.", + entityType: "job", + entityId: "job-1", + metadata: { company: "Empresa X" }, + readAt: null, + createdAt: new Date("2026-07-18T10:00:00.000Z").toISOString(), +}; + +describe("Integration - Notifications Routes", () => { + let app: ReturnType; + const BASE = "/notifications"; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(getIronSession).mockResolvedValue(fixtureSession as any); + mockNotificationsService.list.mockResolvedValue({ + notifications: [fixtureNotification], + unreadCount: 1, + }); + mockNotificationsService.markRead.mockResolvedValue({ + ...fixtureNotification, + readAt: new Date("2026-07-18T10:05:00.000Z").toISOString(), + }); + mockNotificationsService.markAllRead.mockResolvedValue({ updated: 3 }); + + app = createJobsApiApp(); + }); + + it("lista notificações do usuário autenticado", async () => { + const res = await request(app) + .get(`${BASE}?channel=notification&limit=10`) + .expect(200); + + expect(res.body).toHaveProperty("unreadCount", 1); + expect(res.body.notifications).toHaveLength(1); + expect(mockNotificationsService.list).toHaveBeenCalledWith( + "user_abc", + expect.objectContaining({ + channel: "notification", + limit: 10, + }), + ); + }); + + it("valida canal inválido na listagem", async () => { + await request(app).get(`${BASE}?channel=email`).expect(400); + }); + + it("marca uma notificação como lida", async () => { + const res = await request(app) + .patch(`${BASE}/notification-1/read`) + .expect(200); + + expect(res.body).toHaveProperty("readAt"); + expect(mockNotificationsService.markRead).toHaveBeenCalledWith( + "user_abc", + "notification-1", + ); + }); + + it("marca todas as notificações de um canal como lidas", async () => { + const res = await request(app) + .patch(`${BASE}/read-all?channel=message`) + .expect(200); + + expect(res.body).toEqual({ updated: 3 }); + expect(mockNotificationsService.markAllRead).toHaveBeenCalledWith( + "user_abc", + "message", + ); + }); + + it("retorna 404 quando a notificação não existe", async () => { + mockNotificationsService.markRead.mockRejectedValueOnce( + AppError.notFound("Notificação não encontrada"), + ); + + await request(app).patch(`${BASE}/missing/read`).expect(404); + }); + + it("retorna 401 quando sessão não tem userId", async () => { + vi.mocked(getIronSession).mockResolvedValueOnce({ + userId: undefined, + } as any); + + await request(app).get(BASE).expect(401); + }); +}); diff --git a/backend/tests/unit/app.test.ts b/backend/tests/unit/app.test.ts index 7f58d13..f714c15 100644 --- a/backend/tests/unit/app.test.ts +++ b/backend/tests/unit/app.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ cacheSearchKeywords: vi.fn(), + cacheSearchJobIds: vi.fn(), cacheAbsoluteSMembers: vi.fn(), cacheGetJobsByIds: vi.fn(), getCache: vi.fn(), @@ -15,10 +16,13 @@ const mocks = vi.hoisted(() => ({ dbInsert: vi.fn(), dbInsertValues: vi.fn(), dbInsertConflict: vi.fn(), + getUserById: vi.fn(), + createHighMatchIfMissing: vi.fn(), })); vi.mock("../../src/lib/cache.js", () => ({ cacheSearchKeywords: mocks.cacheSearchKeywords, + cacheSearchJobIds: mocks.cacheSearchJobIds, cacheAbsoluteSMembers: mocks.cacheAbsoluteSMembers, cacheGetJobsByIds: mocks.cacheGetJobsByIds, getCache: mocks.getCache, @@ -73,6 +77,18 @@ vi.mock("../../src/routes/users.routes.js", async () => { return { userRoutes: Router() }; }); +vi.mock("../../src/modules/users/users.service.js", () => ({ + UsersService: class { + getUserById = mocks.getUserById; + }, +})); + +vi.mock("../../src/modules/notifications/notifications.service.js", () => ({ + NotificationsService: class { + createHighMatchIfMissing = mocks.createHighMatchIfMissing; + }, +})); + vi.mock("../../src/middleware/withSession.js", () => ({ withSession: (req: any, _res: any, next: any) => { req.session = { userId: "test-user-id" }; @@ -104,6 +120,7 @@ describe("jobsApiApp", () => { DEFAULT_PAGINATED(ids), ); mocks.cacheSearchKeywords.mockResolvedValue(["id-1", "id-2"]); + mocks.cacheSearchJobIds.mockResolvedValue([]); mocks.cacheAbsoluteSMembers.mockResolvedValue(["id-1", "id-2"]); mocks.cacheGetJobsByIds.mockResolvedValue([ { id: "id-1", title: "Dev Java", company: "ACME" }, @@ -119,6 +136,8 @@ describe("jobsApiApp", () => { onConflictDoNothing: mocks.dbInsertConflict, }); mocks.dbInsert.mockReturnValue({ values: mocks.dbInsertValues }); + mocks.getUserById.mockResolvedValue(null); + mocks.createHighMatchIfMissing.mockResolvedValue(undefined); mocks.getCache.mockResolvedValue({ lPush: vi.fn() }); mocks.publish.mockResolvedValue(undefined); }); @@ -272,6 +291,140 @@ describe("jobsApiApp", () => { expect(res.body.total).toBe(1); }); + it("GET /jobs/search usa índices estruturados quando há filtros", async () => { + mocks.cacheSearchJobIds.mockResolvedValue(["id-structured"]); + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-structured", + title: "Dev React Júnior", + company: "ACME", + location: "Brasil - Remoto", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ + keywords: "React", + level: "Júnior", + location: "Brasil", + type: "Remoto", + }) + .expect(200); + + expect(mocks.cacheSearchJobIds).toHaveBeenCalledWith({ + keywords: ["React"], + level: "Júnior", + location: "Brasil", + continent: "", + country: "", + state: "", + city: "", + type: "Remoto", + model: "", + contract: "", + }); + expect(mocks.cacheSearchKeywords).not.toHaveBeenCalled(); + expect(mocks.cacheGetJobsByIds).toHaveBeenCalledWith(["id-structured"]); + expect(res.body.jobs).toEqual([ + expect.objectContaining({ id: "id-structured" }), + ]); + expect(res.body.source).toContain("structured_indexes"); + expect(res.body.source).toContain("verified"); + }); + + it("GET /jobs/search valida resultados dos índices estruturados antes de responder", async () => { + mocks.cacheSearchJobIds.mockResolvedValue([ + "id-good", + "id-pleno", + "id-presencial", + "id-us", + ]); + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-good", + title: "Dev React Júnior", + company: "ACME", + location: "Brasil - Remoto", + }, + { + id: "id-pleno", + title: "Remote Frontend Software Engineer", + company: "Globex", + location: "Brasil - Remoto", + }, + { + id: "id-presencial", + title: "Web Application Full Stack Developer", + company: "Carrier", + location: "Brasil", + }, + { + id: "id-us", + title: "Dev React Júnior", + company: "Affirm", + location: "Miami, Flórida, Estados Unidos, Brasil", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ + level: "Júnior", + location: "Brasil", + type: "Remoto", + }) + .expect(200); + + expect(mocks.cacheGetJobsByIds).toHaveBeenCalledWith([ + "id-good", + "id-pleno", + "id-presencial", + "id-us", + ]); + expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-good" })]); + expect(res.body.total).toBe(1); + expect(res.body.source).toBe("valkey_global_index:structured_indexes:verified"); + }); + + it("GET /jobs/search filtra vagas de estágio e trainee", async () => { + mocks.cacheSearchJobIds.mockResolvedValue(["id-intern", "id-junior"]); + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-intern", + title: "Software Engineering Intern", + company: "ACME", + location: "Brasil - Remoto", + }, + { + id: "id-junior", + title: "Junior Frontend Developer", + company: "Globex", + location: "Brasil - Remoto", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ + level: "Estágio/Trainee", + location: "Brasil", + type: "Remoto", + }) + .expect(200); + + expect(mocks.cacheSearchJobIds).toHaveBeenCalledWith( + expect.objectContaining({ level: "Estágio/Trainee" }), + ); + expect(res.body.jobs).toEqual([ + expect.objectContaining({ id: "id-intern" }), + ]); + expect(res.body.total).toBe(1); + }); + it("GET /jobs/search diferencia modelo híbrido de remoto", async () => { mocks.cacheGetJobsByIds.mockResolvedValue([ { @@ -298,6 +451,47 @@ describe("jobsApiApp", () => { expect(res.body.total).toBe(1); }); + it("GET /jobs/search calcula match por perfil e registra alto match", async () => { + mocks.getUserById.mockResolvedValue({ + id: "test-user-id", + technologies: ["React", "TypeScript"], + technologyExperiencesEncrypted: null, + }); + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-match", + title: "React TypeScript Developer", + company: "ACME", + location: "Brasil - Remote", + url: "https://example.com/jobs/id-match", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ keywords: "React" }) + .expect(200); + + expect(res.body.jobs).toEqual([ + expect.objectContaining({ + id: "id-match", + matchScore: expect.any(Number), + matchSource: "backend_profile", + matchedTechnologies: ["React", "TypeScript"], + }), + ]); + expect(res.body.jobs[0].matchScore).toBeGreaterThanOrEqual(85); + expect(mocks.createHighMatchIfMissing).toHaveBeenCalledWith( + "test-user-id", + expect.objectContaining({ + id: "id-match", + matchScore: expect.any(Number), + matchSource: "backend_profile", + }), + ); + }); + it("GET /jobs/search retorna paginação correta", async () => { mocks.parsePagination.mockReturnValue({ page: 2, limit: 10 }); mocks.paginate.mockReturnValue({ diff --git a/backend/tests/unit/libs/cache.test.ts b/backend/tests/unit/libs/cache.test.ts index 4798bc1..d6b25b4 100644 --- a/backend/tests/unit/libs/cache.test.ts +++ b/backend/tests/unit/libs/cache.test.ts @@ -6,6 +6,9 @@ import { cacheDel, cacheGet, cacheGetJobsByIds, + cacheJobIndexKeys, + cacheClearJobs, + cacheSearchJobIds, cacheSearchKeywords, cacheSet, closeCache, @@ -34,6 +37,8 @@ vi.mock("redis", () => { sMembers: vi.fn(), sCard: vi.fn(), sUnion: vi.fn(), + sendCommand: vi.fn(), + expire: vi.fn(), mGet: vi.fn(), }; return { @@ -156,15 +161,18 @@ describe("Valkey Cache Lib", () => { expect(result).toEqual([]); }); - it("deve normalizar e buscar direto com SMembers se houver apenas uma keyword válida", async () => { - mockClientInstance.sMembers.mockResolvedValue(["job_1"]); + it("deve normalizar e buscar aliases de uma keyword válida", async () => { + mockClientInstance.sUnion.mockResolvedValue(["job_1"]); const result = await cacheSearchKeywords([" UX/UI Designer "]); // "UX/UI Designer" -> "ux ui designer" - expect(mockClientInstance.sMembers).toHaveBeenCalledWith( + expect(mockClientInstance.sUnion).toHaveBeenCalledWith([ "scraper:jobs:keyword:ux ui designer", - ); + "scraper:jobs:keyword:ux", + "scraper:jobs:keyword:ui", + "scraper:jobs:keyword:designer", + ]); expect(result).toEqual(["job_1"]); }); @@ -176,7 +184,106 @@ describe("Valkey Cache Lib", () => { expect(mockClientInstance.sUnion).toHaveBeenCalledWith([ "scraper:jobs:keyword:go", "scraper:jobs:keyword:c#", + "scraper:jobs:keyword:c", + ]); + expect(result).toEqual(["job_1", "job_2"]); + }); + }); + + describe("cacheSearchJobIds", () => { + it("deve montar chaves normalizadas para filtros estruturados", () => { + expect( + cacheJobIndexKeys({ + level: "Júnior", + location: "Brasil", + type: "Híbrido", + contract: "PJ", + }), + ).toEqual([ + "scraper:jobs:level:junior", + "scraper:jobs:location:brasil", + "scraper:jobs:model:hibrido", + "scraper:jobs:contract:pj", + ]); + }); + + it("deve normalizar estágio/trainee para o índice interno de estágio", () => { + expect(cacheJobIndexKeys({ level: "Estágio/Trainee" })).toEqual([ + "scraper:jobs:level:estagio", + ]); + expect(cacheJobIndexKeys({ level: "Trainee" })).toEqual([ + "scraper:jobs:level:estagio", + ]); + }); + + it("deve usar índice global quando não houver keywords nem filtros", async () => { + mockClientInstance.sMembers.mockResolvedValue(["job_1"]); + + const result = await cacheSearchJobIds({}); + + expect(mockClientInstance.sMembers).toHaveBeenCalledWith( + "scraper:jobs:index", + ); + expect(result).toEqual(["job_1"]); + }); + + it("deve intersectar keyword única com filtros estruturados", async () => { + mockClientInstance.sendCommand.mockResolvedValue(["job_1"]); + + const result = await cacheSearchJobIds({ + keywords: ["Node.js"], + level: "Sênior", + country: "Brasil", + model: "Remoto", + }); + + expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(1, [ + "SUNIONSTORE", + expect.stringMatching(/^scraper:jobs:search:/), + "scraper:jobs:keyword:node.js", + "scraper:jobs:keyword:node js", + "scraper:jobs:keyword:nodejs", + "scraper:jobs:keyword:node", + "scraper:jobs:keyword:js", ]); + expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(2, [ + "SINTER", + expect.stringMatching(/^scraper:jobs:search:/), + "scraper:jobs:level:senior", + "scraper:jobs:country:brasil", + "scraper:jobs:model:remoto", + ]); + expect(result).toEqual(["job_1"]); + }); + + it("deve criar união temporária para múltiplas keywords antes da interseção", async () => { + mockClientInstance.sendCommand + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(["job_1", "job_2"]); + + const result = await cacheSearchJobIds({ + keywords: ["React", "Node"], + type: "Remoto", + }); + + expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(1, [ + "SUNIONSTORE", + expect.stringMatching(/^scraper:jobs:search:/), + "scraper:jobs:keyword:react", + "scraper:jobs:keyword:node", + ]); + expect(mockClientInstance.expire).toHaveBeenCalledWith( + expect.stringMatching(/^scraper:jobs:search:/), + 30, + ); + expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(2, [ + "SINTER", + expect.stringMatching(/^scraper:jobs:search:/), + "scraper:jobs:model:remoto", + ]); + expect(mockClientInstance.del).toHaveBeenCalledWith( + expect.stringMatching(/^scraper:jobs:search:/), + ); expect(result).toEqual(["job_1", "job_2"]); }); }); @@ -207,4 +314,58 @@ describe("Valkey Cache Lib", () => { expect(result).toEqual([{ title: "Go Dev" }, { title: "Rust Dev" }]); }); }); + + describe("cacheClearJobs", () => { + it("deve remover payloads e índices de vagas por SCAN em lotes", async () => { + mockClientInstance.sendCommand + .mockResolvedValueOnce(["0", ["scraper:job:1", "scraper:job:2"]]) + .mockResolvedValueOnce([ + "0", + ["scraper:jobs:index", "scraper:jobs:keyword:node"], + ]); + mockClientInstance.del.mockResolvedValueOnce(2).mockResolvedValueOnce(2); + + const result = await cacheClearJobs(); + + expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(1, [ + "SCAN", + "0", + "MATCH", + "scraper:job:*", + "COUNT", + "500", + ]); + expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(2, [ + "SCAN", + "0", + "MATCH", + "scraper:jobs:*", + "COUNT", + "500", + ]); + expect(mockClientInstance.del).toHaveBeenNthCalledWith(1, [ + "scraper:job:1", + "scraper:job:2", + ]); + expect(mockClientInstance.del).toHaveBeenNthCalledWith(2, [ + "scraper:jobs:index", + "scraper:jobs:keyword:node", + ]); + expect(result).toEqual({ + deleted: 4, + patterns: ["scraper:job:*", "scraper:jobs:*"], + }); + }); + + it("não deve chamar DEL quando o SCAN não encontrar chaves", async () => { + mockClientInstance.sendCommand + .mockResolvedValueOnce(["0", []]) + .mockResolvedValueOnce(["0", []]); + + const result = await cacheClearJobs(); + + expect(mockClientInstance.del).not.toHaveBeenCalled(); + expect(result.deleted).toBe(0); + }); + }); }); diff --git a/backend/tests/unit/modules/admin/adminUsers.repository.test.ts b/backend/tests/unit/modules/admin/adminUsers.repository.test.ts index c36df7e..467ad35 100644 --- a/backend/tests/unit/modules/admin/adminUsers.repository.test.ts +++ b/backend/tests/unit/modules/admin/adminUsers.repository.test.ts @@ -102,7 +102,13 @@ describe("AdminUsersRepository", () => { expect(mocks.limit).toHaveBeenCalledWith(10); expect(mocks.offset).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: [user], total: 1, limit: 10, offset: 5 }); + expect(result).toMatchObject({ + data: [expect.objectContaining(user)], + total: 1, + limit: 10, + offset: 5, + }); + expect(result.data[0]).toHaveProperty("technologyExperiences"); }); it("finds many users with default filters", async () => { @@ -114,13 +120,20 @@ describe("AdminUsersRepository", () => { expect(mocks.limit).toHaveBeenCalledWith(50); expect(mocks.offset).toHaveBeenCalledWith(0); - expect(result).toEqual({ data: [user], total: 1, limit: 50, offset: 0 }); + expect(result).toMatchObject({ + data: [expect.objectContaining(user)], + total: 1, + limit: 50, + offset: 0, + }); + expect(result.data[0]).toHaveProperty("technologyExperiences"); }); it("finds user by id", async () => { - await expect(new AdminUsersRepository().findById("user-1")).resolves.toEqual( - user, - ); + const result = await new AdminUsersRepository().findById("user-1"); + + expect(result).toMatchObject(user); + expect(result).toHaveProperty("technologyExperiences"); }); it("updates blocked state and role", async () => { @@ -171,7 +184,10 @@ describe("AdminUsersRepository", () => { }); it("deletes user", async () => { - await expect(new AdminUsersRepository().delete("user-1")).resolves.toEqual(user); + const result = await new AdminUsersRepository().delete("user-1"); + + expect(result).toMatchObject(user); + expect(result).toHaveProperty("technologyExperiences"); }); it("returns null when deleting affects no rows", async () => { diff --git a/backend/tests/unit/modules/admin/scrapers.test.ts b/backend/tests/unit/modules/admin/scrapers.test.ts index 96a42b2..8d625d6 100644 --- a/backend/tests/unit/modules/admin/scrapers.test.ts +++ b/backend/tests/unit/modules/admin/scrapers.test.ts @@ -88,7 +88,7 @@ describe("scraperClient", () => { }), ); - await expect(scraperClient.getJobs()).resolves.toEqual({ + await expect(scraperClient.getJobs(50)).resolves.toEqual({ ok: true, jobs: [], total: 0, @@ -105,7 +105,7 @@ describe("scraperClient", () => { }); expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/jobs"), + expect.stringContaining("/jobs?limit=50"), expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( diff --git a/backend/tests/unit/modules/auth/credentials.service.test.ts b/backend/tests/unit/modules/auth/credentials.service.test.ts index c71ec76..988f0d2 100644 --- a/backend/tests/unit/modules/auth/credentials.service.test.ts +++ b/backend/tests/unit/modules/auth/credentials.service.test.ts @@ -194,7 +194,8 @@ describe("CredentialsService", () => { const result = await service.login(loginInput); - expect(result.user).toEqual(mockUser); + expect(result.user).toMatchObject(mockUser); + expect(result.user).toHaveProperty("technologyExperiences"); expect(result.session).toEqual({ userId: mockUser.id, role: "user" }); }); diff --git a/backend/tests/unit/modules/jobs/jobMatch.service.test.ts b/backend/tests/unit/modules/jobs/jobMatch.service.test.ts new file mode 100644 index 0000000..6eb3c75 --- /dev/null +++ b/backend/tests/unit/modules/jobs/jobMatch.service.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import type { User } from "../../../../src/db/schema"; +import { encryptText } from "../../../../src/lib/security/encryption"; +import { + getUserMatchTechnologies, + jobNotificationIdentity, + scoreJobWithTechnologies, +} from "../../../../src/modules/jobs/jobMatch.service"; + +const originalEnv = { + ENCRYPTION_MASTER_KEY: process.env.ENCRYPTION_MASTER_KEY, + ENCRYPTION_KEY_ID: process.env.ENCRYPTION_KEY_ID, + SEARCH_KEY: process.env.SEARCH_KEY, +}; + +function setValidSecurityEnv() { + process.env.ENCRYPTION_MASTER_KEY = + "0000000000000000000000000000000000000000000000000000000000000000"; + process.env.ENCRYPTION_KEY_ID = "job-match-test"; + process.env.SEARCH_KEY = "job-match-search-key"; +} + +function baseUser(overrides: Partial = {}): User { + return { + id: "user-1", + firstName: null, + firstNameEncrypted: null, + lastName: null, + lastNameEncrypted: null, + displayName: null, + displayNameEncrypted: null, + username: "user", + email: "user@example.com", + emailEncrypted: null, + emailHash: null, + emailVerified: false, + avatarUrl: null, + avatarUrlEncrypted: null, + phone: null, + phoneEncrypted: null, + cpf: null, + cpfEncrypted: null, + cpfHash: null, + technologies: null, + technologiesEncrypted: null, + technologyExperiencesEncrypted: null, + level: null, + levelEncrypted: null, + role: "user", + isBlocked: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + lastLoginAt: null, + ...overrides, + }; +} + +describe("jobMatch.service", () => { + it("retorna lista vazia quando usuário não existe", () => { + expect(getUserMatchTechnologies(null)).toEqual([]); + expect(getUserMatchTechnologies(undefined)).toEqual([]); + }); + + it("extrai experiências criptografadas do usuário", () => { + setValidSecurityEnv(); + + const user = baseUser({ + technologyExperiencesEncrypted: encryptText( + JSON.stringify([ + { name: "TypeScript", years: 4 }, + { name: "Node.js", years: -1 }, + { name: "", years: 10 }, + null, + ]), + ), + }); + + expect(getUserMatchTechnologies(user)).toEqual([ + { name: "TypeScript", years: 4 }, + { name: "Node.js", years: 0 }, + ]); + + process.env.ENCRYPTION_MASTER_KEY = originalEnv.ENCRYPTION_MASTER_KEY; + process.env.ENCRYPTION_KEY_ID = originalEnv.ENCRYPTION_KEY_ID; + process.env.SEARCH_KEY = originalEnv.SEARCH_KEY; + }); + + it("usa technologies como fallback quando não há experiências", () => { + const user = baseUser({ + technologies: ["React", " ", "Node.js"], + }); + + expect(getUserMatchTechnologies(user)).toEqual([ + { name: "React", years: 1 }, + { name: "Node.js", years: 1 }, + ]); + }); + + it("mantém a vaga sem score quando o perfil não tem tecnologias", () => { + const job = { title: "Backend Engineer" }; + + expect(scoreJobWithTechnologies(job, [])).toBe(job); + }); + + it("calcula match com alias Node.js, pesos por anos e arrays do job", () => { + const result = scoreJobWithTechnologies( + { + title: "Backend Engineer", + keywords: ["nodejs", "api"], + description: "APIs com TypeScript", + }, + [ + { name: "Node.js", years: 3 }, + { name: "TypeScript", years: 2 }, + { name: "React", years: 1 }, + ], + ); + + expect(result.matchSource).toBe("backend_profile"); + expect(result.matchedTechnologies).toEqual(["Node.js", "TypeScript"]); + expect(result.matchScore).toBeGreaterThanOrEqual(85); + }); + + it("retorna score base quando nenhuma tecnologia bate", () => { + const result = scoreJobWithTechnologies( + { title: "Product Manager" }, + [{ name: "Go", years: 5 }], + ); + + expect(result.matchScore).toBe(45); + expect(result.matchedTechnologies).toEqual([]); + }); + + it("resolve identidade da notificação por url, jobLink ou id", () => { + expect(jobNotificationIdentity({ url: " https://job.test/1 " })).toBe( + "https://job.test/1", + ); + expect(jobNotificationIdentity({ jobLink: "https://job.test/2" } as any)).toBe( + "https://job.test/2", + ); + expect(jobNotificationIdentity({ id: "job-3" })).toBe("job-3"); + }); +}); diff --git a/backend/tests/unit/modules/notifications/notifications.service.test.ts b/backend/tests/unit/modules/notifications/notifications.service.test.ts new file mode 100644 index 0000000..00a1b31 --- /dev/null +++ b/backend/tests/unit/modules/notifications/notifications.service.test.ts @@ -0,0 +1,308 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + NewUserNotification, + SavedJob, + UserNotification, +} from "../../../../src/db/schema"; +import { NotificationsService } from "../../../../src/modules/notifications/notifications.service"; + +const baseDate = new Date("2026-07-21T10:00:00.000Z"); + +const savedJob: SavedJob = { + id: "job-1", + userId: "user-1", + jobLink: "https://jobs.test/1", + jobTitle: "Backend Developer", + company: "Candidate", + location: "Brasil", + source: "LinkedIn", + keyword: "Node.js Developer", + status: "saved", + appliedAt: null, + notes: null, + createdAt: baseDate, + updatedAt: baseDate, +}; + +const notification: UserNotification = { + id: "notification-1", + userId: "user-1", + channel: "notification", + type: "job_saved", + title: "Vaga salva", + message: "Backend Developer na Candidate foi adicionada.", + entityType: "job", + entityId: "job-1", + metadata: {}, + readAt: null, + createdAt: baseDate, +}; + +function makeListBuilder(items: UserNotification[]) { + return { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(items), + }; +} + +function makeCountBuilder(value: number) { + return { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([{ value }]), + }; +} + +function makeInsertBuilder(result: UserNotification = notification) { + const returning = vi.fn().mockResolvedValue([result]); + const values = vi.fn().mockReturnValue({ returning }); + return { builder: { values }, values, returning }; +} + +function makeUpdateBuilder(result: Partial[] = [notification]) { + const returning = vi.fn().mockResolvedValue(result); + const where = vi.fn().mockReturnValue({ returning }); + const set = vi.fn().mockReturnValue({ where }); + return { builder: { set }, set, where, returning }; +} + +function makeTx() { + return { + query: { + userNotifications: { + findFirst: vi.fn(), + }, + }, + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }; +} + +describe("NotificationsService", () => { + let tx: ReturnType; + let service: NotificationsService; + + beforeEach(() => { + tx = makeTx(); + service = new NotificationsService(tx as any); + }); + + it("lista notificações com filtros de canal e não lidas", async () => { + const listBuilder = makeListBuilder([notification]); + const countBuilder = makeCountBuilder(3); + tx.select.mockReturnValueOnce(listBuilder).mockReturnValueOnce(countBuilder); + + const result = await service.list("user-1", { + channel: "notification", + unreadOnly: true, + limit: 10, + }); + + expect(result).toEqual({ + notifications: [notification], + unreadCount: 3, + }); + expect(listBuilder.limit).toHaveBeenCalledWith(10); + expect(tx.select).toHaveBeenCalledTimes(2); + }); + + it("usa unreadCount zero quando a consulta agregada não retorna linhas", async () => { + tx.select + .mockReturnValueOnce(makeListBuilder([])) + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + }); + + await expect( + service.list("user-1", { limit: 5 }), + ).resolves.toMatchObject({ + notifications: [], + unreadCount: 0, + }); + }); + + it("cria uma notificação arbitrária", async () => { + const insert = makeInsertBuilder(notification); + tx.insert.mockReturnValue(insert.builder); + + const data: NewUserNotification = { + userId: "user-1", + channel: "message", + type: "system", + title: "Mensagem", + message: "Olá", + }; + + await expect(service.create(data)).resolves.toBe(notification); + expect(insert.values).toHaveBeenCalledWith(data); + }); + + it("marca uma notificação como lida", async () => { + const update = makeUpdateBuilder([notification]); + tx.update.mockReturnValue(update.builder); + + await expect( + service.markRead("user-1", "notification-1"), + ).resolves.toBe(notification); + expect(update.set).toHaveBeenCalledWith({ readAt: expect.any(Date) }); + }); + + it("lança not found ao marcar como lida quando não encontra a notificação", async () => { + const update = makeUpdateBuilder([]); + tx.update.mockReturnValue(update.builder); + + await expect( + service.markRead("user-1", "missing"), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Notificação não encontrada", + }); + }); + + it("marca todas as notificações não lidas como lidas", async () => { + const update = makeUpdateBuilder([{ id: "n1" }, { id: "n2" }]); + tx.update.mockReturnValue(update.builder); + + await expect(service.markAllRead("user-1", "message")).resolves.toEqual({ + updated: 2, + }); + }); + + it("cria notificação para vaga salva", async () => { + const insert = makeInsertBuilder(notification); + tx.insert.mockReturnValue(insert.builder); + + await service.createForSavedJob("user-1", savedJob); + + expect(insert.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + channel: "notification", + type: "job_saved", + title: "Vaga salva", + message: "Backend Developer na Candidate foi adicionada às suas vagas salvas.", + entityType: "job", + entityId: "job-1", + metadata: expect.objectContaining({ + jobLink: "https://jobs.test/1", + status: "saved", + }), + }), + ); + }); + + it("cria notificação para candidatura enviada com fallback de nome da vaga", async () => { + const insert = makeInsertBuilder(notification); + tx.insert.mockReturnValue(insert.builder); + + await service.createForSavedJob("user-1", { + ...savedJob, + jobTitle: " ", + company: null, + status: "applied", + }); + + expect(insert.values).toHaveBeenCalledWith( + expect.objectContaining({ + type: "job_applied", + title: "Candidatura enviada", + message: "Sua candidatura para vaga foi registrada.", + }), + ); + }); + + it("não cria notificação quando o status não mudou", async () => { + await expect( + service.createForJobStatusChange("user-1", savedJob, savedJob), + ).resolves.toBeNull(); + + expect(tx.insert).not.toHaveBeenCalled(); + }); + + it("cria notificação para mudança de status", async () => { + const insert = makeInsertBuilder(notification); + tx.insert.mockReturnValue(insert.builder); + + await service.createForJobStatusChange("user-1", savedJob, { + ...savedJob, + status: "interviewing", + }); + + expect(insert.values).toHaveBeenCalledWith( + expect.objectContaining({ + type: "job_status_changed", + title: "Status de candidatura atualizado", + message: 'Backend Developer na Candidate agora está em "Em entrevista".', + metadata: expect.objectContaining({ + previousStatus: "saved", + status: "interviewing", + }), + }), + ); + }); + + it("não cria high match abaixo do limite ou sem identidade", async () => { + await expect( + service.createHighMatchIfMissing("user-1", { + id: "job-low", + title: "React Developer", + matchScore: 84, + }), + ).resolves.toBeNull(); + await expect( + service.createHighMatchIfMissing("user-1", { + id: null, + url: " ", + title: "React Developer", + matchScore: 90, + }), + ).resolves.toBeNull(); + + expect(tx.insert).not.toHaveBeenCalled(); + }); + + it("reaproveita high match existente", async () => { + tx.query.userNotifications.findFirst.mockResolvedValue(notification); + + await expect( + service.createHighMatchIfMissing("user-1", { + url: "https://jobs.test/react", + title: "React Developer", + matchScore: 90, + }), + ).resolves.toBe(notification); + + expect(tx.insert).not.toHaveBeenCalled(); + }); + + it("cria high match novo com tecnologias encontradas", async () => { + const insert = makeInsertBuilder(notification); + tx.query.userNotifications.findFirst.mockResolvedValue(undefined); + tx.insert.mockReturnValue(insert.builder); + + await service.createHighMatchIfMissing("user-1", { + url: "https://jobs.test/react", + title: "React Developer", + company: "Candidate", + matchScore: 92, + matchedTechnologies: ["React", "TypeScript"], + }); + + expect(insert.values).toHaveBeenCalledWith( + expect.objectContaining({ + type: "high_match", + title: "Vaga com alto match encontrada", + message: + "React Developer na Candidate tem 92% de compatibilidade com o seu perfil.", + entityId: "https://jobs.test/react", + metadata: expect.objectContaining({ + matchedTechnologies: ["React", "TypeScript"], + matchScore: 92, + }), + }), + ); + }); +}); diff --git a/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts b/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts index eacc9b1..dfa5913 100644 --- a/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts +++ b/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts @@ -95,17 +95,36 @@ describe("SavedJobsService", () => { }; it("cria e retorna a vaga quando não existe duplicata", async () => { - tx.query.savedJobs.findFirst.mockResolvedValue(undefined); - tx.insert.mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([{ ...mockJob, ...newJobData }]), - }), + const createdJob = { ...mockJob, ...newJobData }; + const savedJobValues = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([createdJob]), + }); + const notificationValues = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: "notification-1" }]), }); + tx.query.savedJobs.findFirst.mockResolvedValue(undefined); + tx.insert + .mockReturnValueOnce({ values: savedJobValues }) + .mockReturnValueOnce({ values: notificationValues }); + const result = await service.create("user-1", newJobData); expect(result).toMatchObject(newJobData); - expect(tx.insert).toHaveBeenCalledOnce(); + expect(tx.insert).toHaveBeenCalledTimes(2); + expect(savedJobValues).toHaveBeenCalledWith({ + ...newJobData, + userId: "user-1", + }); + expect(notificationValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + channel: "notification", + type: "job_saved", + entityType: "job", + entityId: createdJob.id, + }), + ); }); it("lança CONFLICT quando jobLink já existe para o usuário", async () => { @@ -124,6 +143,7 @@ describe("SavedJobsService", () => { describe("update", () => { it("atualiza e retorna a vaga", async () => { + tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); tx.update.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -172,6 +192,7 @@ describe("SavedJobsService", () => { returning: vi.fn().mockResolvedValue([mockJob]), }), }); + tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); tx.update.mockReturnValue({ set: setMock }); await service.update("user-1", "job-1", { jobTitle: "X" }); @@ -180,6 +201,44 @@ describe("SavedJobsService", () => { expect(setArg).toHaveProperty("updatedAt"); expect(setArg.updatedAt).toBeInstanceOf(Date); }); + + it("cria notificação quando o status da vaga muda", async () => { + const previousJob = { ...mockJob, status: "saved" }; + const updatedJob = { ...mockJob, status: "applied" }; + const notificationValues = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: "notification-1" }]), + }); + + tx.query.savedJobs.findFirst.mockResolvedValue(previousJob); + tx.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedJob]), + }), + }), + }); + tx.insert.mockReturnValueOnce({ values: notificationValues }); + + const result = await service.update("user-1", "job-1", { + status: "applied", + }); + + expect(result.status).toBe("applied"); + expect(tx.insert).toHaveBeenCalledOnce(); + expect(notificationValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + channel: "notification", + type: "job_applied", + entityType: "job", + entityId: updatedJob.id, + metadata: expect.objectContaining({ + previousStatus: "saved", + status: "applied", + }), + }), + ); + }); }); // ── delete ───────────────────────────────────────────────────────────────── diff --git a/backend/tests/unit/modules/users/functions/findUsers.test.ts b/backend/tests/unit/modules/users/functions/findUsers.test.ts index bc5c3c4..e0559bf 100644 --- a/backend/tests/unit/modules/users/functions/findUsers.test.ts +++ b/backend/tests/unit/modules/users/functions/findUsers.test.ts @@ -63,7 +63,7 @@ describe("findUserByProvider", () => { { provider: "github", providerAccountId: "gh-abc" }, tx as any, ); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); }); it("retorna null quando account nao existe", async () => { @@ -116,7 +116,8 @@ describe("findUserByEmail", () => { it("retorna o usuario quando email existe", async () => { const tx = makeTx(undefined, mockUser); const result = await findUserByEmail("user@example.com", tx as any); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); + expect(result).toHaveProperty("technologyExperiences"); }); it("retorna undefined quando email nao existe", async () => { @@ -136,6 +137,7 @@ describe("findUserByEmail", () => { const result = await findUserByEmail("user@example.com"); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); + expect(result).toHaveProperty("technologyExperiences"); }); }); diff --git a/backend/tests/unit/modules/users/functions/users.functions.test.ts b/backend/tests/unit/modules/users/functions/users.functions.test.ts index e1e3318..1e82663 100644 --- a/backend/tests/unit/modules/users/functions/users.functions.test.ts +++ b/backend/tests/unit/modules/users/functions/users.functions.test.ts @@ -138,7 +138,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); }); it("encontra por email, cria account e retorna usuário existente", async () => { @@ -152,7 +152,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); expect(mocks.createAccountMock).toHaveBeenCalledWith( { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), @@ -187,7 +187,7 @@ describe("findOrCreateUser", () => { { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), ); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); }); it("pula a busca por email quando profile.email é undefined", async () => { @@ -238,7 +238,8 @@ describe("createUser", () => { tx as any, ); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); + expect(result).toHaveProperty("technologyExperiences"); expect(mocks.generateUsername).not.toHaveBeenCalled(); }); diff --git a/backend/tests/unit/modules/users/users.mapper.test.ts b/backend/tests/unit/modules/users/users.mapper.test.ts index a746b0f..f6842e3 100644 --- a/backend/tests/unit/modules/users/users.mapper.test.ts +++ b/backend/tests/unit/modules/users/users.mapper.test.ts @@ -43,6 +43,7 @@ function baseUser(overrides: Partial = {}): User { cpfHash: null, technologies: null, technologiesEncrypted: null, + technologyExperiencesEncrypted: null, level: null, levelEncrypted: null, role: "user", @@ -73,6 +74,7 @@ describe("users.mapper", () => { phone: "+5534999999999", cpf: "123.456.789-01", technologies: ["TypeScript", "Node.js"], + technologyExperiences: [{ name: "TypeScript", years: 4 }], level: "pleno", }); @@ -97,6 +99,9 @@ describe("users.mapper", () => { expect(values.cpfEncrypted).toMatch(/^v1:mapper-test:/); expect(values.cpfHash).toHaveLength(64); expect(values.technologiesEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.technologyExperiencesEncrypted).toMatch( + /^v1:mapper-test:/, + ); expect(values.levelEncrypted).toMatch(/^v1:mapper-test:/); }); @@ -111,6 +116,7 @@ describe("users.mapper", () => { phone: "+5511999999999", cpf: "987.654.321-00", technologies: ["Go"], + technologyExperiences: [{ name: "Go", years: 2 }], level: "senior", }); @@ -132,6 +138,9 @@ describe("users.mapper", () => { expect(values.cpfEncrypted).toMatch(/^v1:mapper-test:/); expect(values.cpfHash).toHaveLength(64); expect(values.technologiesEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.technologyExperiencesEncrypted).toMatch( + /^v1:mapper-test:/, + ); expect(values.levelEncrypted).toMatch(/^v1:mapper-test:/); expect(toUserUpdateValues({ username: "grace" })).toEqual({ @@ -152,6 +161,9 @@ describe("users.mapper", () => { phoneEncrypted: encryptText("+5534999999999"), cpfEncrypted: encryptText("12345678901"), technologiesEncrypted: encryptText(JSON.stringify(["TypeScript"])), + technologyExperiencesEncrypted: encryptText( + JSON.stringify([{ name: "TypeScript", years: 4 }]), + ), levelEncrypted: encryptText("pleno"), }), ); @@ -165,6 +177,7 @@ describe("users.mapper", () => { phone: "+5534999999999", cpf: "12345678901", technologies: ["TypeScript"], + technologyExperiences: [{ name: "TypeScript", years: 4 }], level: "pleno", }); }); @@ -236,6 +249,9 @@ describe("users.mapper", () => { cpfEncrypted: null, cpfHash: null, technologiesEncrypted: expect.stringMatching(/^v1:mapper-test:/), + technologyExperiencesEncrypted: expect.stringMatching( + /^v1:mapper-test:/, + ), levelEncrypted: null, }); @@ -244,6 +260,7 @@ describe("users.mapper", () => { phone: null, cpf: null, technologies: null, + technologyExperiences: null, level: null, }), ).toMatchObject({ @@ -254,6 +271,7 @@ describe("users.mapper", () => { cpfHash: null, technologies: null, technologiesEncrypted: null, + technologyExperiencesEncrypted: null, level: null, levelEncrypted: null, }); diff --git a/backend/tests/unit/modules/users/users.service.test.ts b/backend/tests/unit/modules/users/users.service.test.ts index 61a6b2e..a6d22ee 100644 --- a/backend/tests/unit/modules/users/users.service.test.ts +++ b/backend/tests/unit/modules/users/users.service.test.ts @@ -52,7 +52,8 @@ describe("UsersService", () => { const result = await service.getUserById("user-1"); - expect(result).toEqual(mockUser); + expect(result).toMatchObject(mockUser); + expect(result).toHaveProperty("technologyExperiences"); expect(tx.query.users.findFirst).toHaveBeenCalledOnce(); }); diff --git a/front_admin/src/lib/api/scrapers.api.ts b/front_admin/src/lib/api/scrapers.api.ts index 953018b..e5c5864 100644 --- a/front_admin/src/lib/api/scrapers.api.ts +++ b/front_admin/src/lib/api/scrapers.api.ts @@ -9,8 +9,13 @@ import type { export const scrapersApi = { list: () => api.get("/admin/scrapers"), status: () => api.get("/admin/scrapers/status"), - jobs: () => api.get("/admin/scrapers/jobs"), + jobs: (limit = 200) => + api.get(`/admin/scrapers/jobs?limit=${limit}`), jobsCount: () => api.get("/admin/scrapers/jobs/count"), trigger: () => api.post<{ ok: boolean; message: string }>("/admin/scrapers/run"), + clearJobsCache: () => + api.delete<{ ok: boolean; deleted: number; patterns: string[] }>( + "/admin/jobs/cache", + ), }; diff --git a/front_admin/src/modules/scrapers/ScrapersPage.tsx b/front_admin/src/modules/scrapers/ScrapersPage.tsx index 3e28694..6942026 100644 --- a/front_admin/src/modules/scrapers/ScrapersPage.tsx +++ b/front_admin/src/modules/scrapers/ScrapersPage.tsx @@ -7,8 +7,10 @@ import { Link2, RefreshCw, ServerCog, + Trash2, } from "lucide-react"; import { formatNumber } from "../../utils/formatNumber"; +import { useAuth } from "../auth/hooks/useAuth"; import { EventConsole } from "./components/EventConsole/EventConsole"; import { ScraperGrid } from "./components/ScraperGrid/ScraperGrid"; import { useScrapers } from "./hooks/useScrapers"; @@ -233,6 +235,7 @@ function JobsPanel({ jobs }: { jobs: ScraperJobPreview[] }) { } export function ScrapersPage() { + const { isLoggedIn } = useAuth(); const { scrapers, adapterStats, @@ -242,14 +245,17 @@ export function ScrapersPage() { isLoading, isRefreshing, isStarting, + isClearingJobsCache, error, refresh, toggleScraper, startAll, pauseAll, + clearJobsCache, clearLogs, refreshIntervalMs, } = useScrapers(); + const canClearJobsCache = isLoggedIn?.role === "super_admin"; return (
@@ -271,18 +277,41 @@ export function ScrapersPage() {
- +
+ + +
diff --git a/front_admin/src/modules/scrapers/components/ScraperGrid/ScraperGrid.tsx b/front_admin/src/modules/scrapers/components/ScraperGrid/ScraperGrid.tsx index 20cfea9..220d105 100644 --- a/front_admin/src/modules/scrapers/components/ScraperGrid/ScraperGrid.tsx +++ b/front_admin/src/modules/scrapers/components/ScraperGrid/ScraperGrid.tsx @@ -16,6 +16,9 @@ export function ScraperGrid({ onStartAll, onPauseAll, }: ScraperGridProps) { + const hasRunningScraper = scrapers.some((scraper) => scraper.active); + const startDisabled = isStarting || hasRunningScraper; + return (
@@ -31,10 +34,19 @@ export function ScraperGrid({
) -} \ No newline at end of file +} diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index d4ec297..a7f7e6a 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -1,5 +1,5 @@ import { useAuth } from "@/domains/auth/application/AuthContext"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { DashboardTab } from "./components/dashboard/DashboardTab"; import { HelpTab } from "./components/help/HelpTab"; @@ -16,9 +16,12 @@ import { jobStatuses } from "./constants"; import { useDashboardJobs } from "./hooks/useDashboardJobs"; import { useUserDashboardData } from "./hooks/useUserDashboardData"; import type { + CareerChecklist, + Job, JobStatus, NewJob, SearchPreferences, + TechnologyExperience, UserProfile, } from "./types"; import { @@ -36,6 +39,109 @@ function getSection(pathname: string) { return "home"; } +function normalizeMatchText(value: string) { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function matchAliases(technology: string) { + const normalized = normalizeMatchText(technology); + const aliases = new Set([normalized]); + + if (normalized.endsWith(" js")) { + aliases.add(normalized.replace(/\s+js$/, "js")); + } + if (normalized.endsWith("js") && normalized.length > 2) { + aliases.add(normalized.replace(/js$/, " js")); + } + + return [...aliases].filter(Boolean); +} + +function textMatchesAlias(text: string, alias: string) { + if (!alias) return false; + if (alias.includes(" ")) return text.includes(alias); + return ` ${text} `.includes(` ${alias} `); +} + +function jobMatchText(job: Job) { + const rawPayload = job.rawPayload ?? {}; + const rawValues = Object.values(rawPayload).flatMap((value) => + Array.isArray(value) ? value : [value], + ); + + return normalizeMatchText( + [ + job.jobTitle, + job.company, + job.location, + job.type, + job.level, + job.tags.join(" "), + ...rawValues.map((value) => (typeof value === "string" ? value : "")), + ].join(" "), + ); +} + +function scoreJobWithTechnologies( + job: Job, + technologies: TechnologyExperience[], +): Job { + if (job.rawPayload?.matchSource === "backend_profile") return job; + + const normalizedTechnologies = [ + ...new Set( + technologies + .filter((technology) => technology.name.trim()) + .map((technology) => ({ + name: technology.name.trim(), + years: Math.max(0, technology.years), + })), + ), + ]; + if (normalizedTechnologies.length === 0) return job; + + const text = jobMatchText(job); + const matchedTechnologies = normalizedTechnologies.filter((technology) => + matchAliases(technology.name).some((alias) => + textMatchesAlias(text, alias), + ), + ); + + const totalWeight = normalizedTechnologies.reduce( + (total, technology) => total + Math.max(1, technology.years), + 0, + ); + const matchedWeight = matchedTechnologies.reduce( + (total, technology) => total + Math.max(1, technology.years), + 0, + ); + const coverage = matchedWeight / totalWeight; + const score = + matchedTechnologies.length === 0 + ? 45 + : Math.min( + 99, + 55 + + Math.round(coverage * 35) + + Math.min(matchedTechnologies.length * 4, 9), + ); + + return { + ...job, + matchScore: score, + rawPayload: { + ...(job.rawPayload ?? {}), + matchedTechnologies: matchedTechnologies.map((item) => item.name), + }, + }; +} + export default function NewDashboardPage() { const { user, refreshUser } = useAuth(); const location = useLocation(); @@ -50,6 +156,7 @@ export default function NewDashboardPage() { const [selectedJobId, setSelectedJobId] = useState(null); const [isAddJobOpen, setIsAddJobOpen] = useState(false); const [toast, setToast] = useState(""); + const checklistSaveTimeout = useRef(null); const showToast = useCallback((message: string) => setToast(message), []); const { userProfile, @@ -74,8 +181,22 @@ export default function NewDashboardPage() { saveJobNotes, } = useDashboardJobs(user, { onError: showToast }); + const matchedTrackedJobs = useMemo( + () => + trackedJobs.map((job) => + scoreJobWithTechnologies(job, userProfile.technologyExperiences), + ), + [trackedJobs, userProfile.technologyExperiences], + ); + const matchedRecommendedJobs = useMemo( + () => + recommendedJobs.map((job) => + scoreJobWithTechnologies(job, userProfile.technologyExperiences), + ), + [recommendedJobs, userProfile.technologyExperiences], + ); const selectedJob = - [...trackedJobs, ...recommendedJobs].find( + [...matchedTrackedJobs, ...matchedRecommendedJobs].find( (job) => job.id === selectedJobId, ) ?? null; const section = getSection(location.pathname); @@ -86,6 +207,15 @@ export default function NewDashboardPage() { return () => window.clearTimeout(timeout); }, [toast]); + useEffect( + () => () => { + if (checklistSaveTimeout.current) { + window.clearTimeout(checklistSaveTimeout.current); + } + }, + [], + ); + const handleSaveProfile = async (profile: UserProfile) => { try { await saveUserProfile(profile); @@ -105,6 +235,28 @@ export default function NewDashboardPage() { } }; + const handleCareerChecklistChange = useCallback( + (careerChecklist: CareerChecklist[]) => { + const nextPreferences = { + ...searchPreferences, + careerChecklist, + }; + + setSearchPreferences(nextPreferences); + + if (checklistSaveTimeout.current) { + window.clearTimeout(checklistSaveTimeout.current); + } + + checklistSaveTimeout.current = window.setTimeout(() => { + void saveSearchPreferences(nextPreferences).catch(() => { + // O hook já publica a mensagem de erro para o usuário. + }); + }, 600); + }, + [saveSearchPreferences, searchPreferences, setSearchPreferences], + ); + const handleStatusChange = async (jobId: string, status: JobStatus) => { try { const updatedJob = await changeJobStatus(jobId, status); @@ -142,32 +294,6 @@ export default function NewDashboardPage() { } }; - // const buildRecommendationSearch = () => { - // const typedKeywords = parseSearchKeywords(searchQuery); - // const filters = { - // ...(filterLevel !== "Todos" ? { level: filterLevel } : {}), - // ...(filterType !== "Todos" ? { type: filterType } : {}), - // ...(countryFilter !== "Todos" ? { location: countryFilter } : {}), - // }; - - // return { - // keywords: - // typedKeywords.length > 0 ? typedKeywords : searchPreferences.keywords, - // filters, - // }; - // }; - - // const handleSearchJobs = async () => { - // try { - // const { keywords, filters } = buildRecommendationSearch(); - - // await refreshRecommendations(keywords, filters, 1); - // showToast("Vagas recomendadas atualizadas."); - // } catch { - // // A camada de dados já apresentou o erro retornado pela API. - // } - // }; - const handleRecommendationPageChange = async (page: number) => { try { await changeRecommendationsPage(page); @@ -180,13 +306,17 @@ export default function NewDashboardPage() { const typedKeywords = parseSearchKeywords(searchQuery); const filters = { ...(filterLevel !== "Todos" ? { level: filterLevel } : {}), - ...(filterType !== "Todos" ? { type: filterType } : {}), - ...(countryFilter !== "Todos" ? { location: countryFilter } : {}), + ...(filterType !== "Todos" + ? { type: filterType, model: filterType } + : {}), + ...(continentFilter !== "Todos" ? { continent: continentFilter } : {}), + ...(countryFilter !== "Todos" + ? { country: countryFilter, location: countryFilter } + : {}), }; return { - keywords: - typedKeywords.length > 0 ? typedKeywords : searchPreferences.keywords, + keywords: typedKeywords, filters, }; }; @@ -201,6 +331,7 @@ export default function NewDashboardPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ countryFilter, + continentFilter, filterLevel, filterType, refreshRecommendations, @@ -224,7 +355,14 @@ export default function NewDashboardPage() { return () => window.clearTimeout(timeoutId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchQuery, filterType, filterLevel, countryFilter, section]); + }, [ + searchQuery, + filterType, + filterLevel, + continentFilter, + countryFilter, + section, + ]); const handleRecommendationPageSizeChange = async (limit: number) => { try { @@ -240,8 +378,8 @@ export default function NewDashboardPage() { case "dashboard": return ( setSelectedJobId(job.id)} onStatusChange={handleStatusChange} onAddJob={() => setIsAddJobOpen(true)} @@ -250,7 +388,7 @@ export default function NewDashboardPage() { case "vagas": return ( navigate("/vagas")} /> ); diff --git a/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx b/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx index f47cc24..bd8e49a 100644 --- a/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx +++ b/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx @@ -1,10 +1,10 @@ import { BriefcaseBusiness, Plus } from "lucide-react"; -import type { Job, JobStatus } from "../../types"; +import type { Job, JobStatus, TechnologyExperience } from "../../types"; import { KanbanBoard } from "./KanbanBoard"; interface DashboardTabProps { jobs: Job[]; - technologies: string[]; + technologies: TechnologyExperience[]; onOpenJob: (job: Job) => void; onStatusChange: (jobId: string, status: JobStatus) => void; onAddJob?: () => void; @@ -42,6 +42,16 @@ export function DashboardTab({ }, ]; + function proficiencyPercent(years: number) { + if (years <= 0) return 15; + if (years < 1) return 25; + if (years < 2) return 40; + if (years < 3) return 55; + if (years < 5) return 70; + if (years < 8) return 85; + return 95; + } + return (
@@ -109,19 +119,21 @@ export function DashboardTab({

Minhas Habilidades Técnicas

- {technologies.slice(0, 5).map((tech, index) => { - const percent = 95 - index * 10; + {technologies.map((tech) => { + const percent = proficiencyPercent(tech.years); return ( -
- {tech} +
+ {tech.name}
- {percent}% + + {percent}% · {tech.years}a +
); })} diff --git a/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx b/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx index 6c4a13e..a1155d1 100644 --- a/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx +++ b/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx @@ -1,5 +1,6 @@ import { ListChecks, Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import type { CareerChecklist as CareerChecklistList } from "../../types"; type ChecklistItem = { id: string; @@ -14,7 +15,6 @@ type ChecklistList = { items: ChecklistItem[]; }; -// TODO: mover para persistência por usuário no backend (preferências ou módulo próprio de checklists). const STORAGE_KEY = "new-dashboard-career-checklists"; function createId() { @@ -47,10 +47,21 @@ function loadLists(): ChecklistList[] { } } -export function CareerChecklist() { - const [lists, setLists] = useState(() => loadLists()); +interface CareerChecklistProps { + lists?: CareerChecklistList[]; + onChange?: (lists: CareerChecklistList[]) => void; +} + +export function CareerChecklist({ + lists: controlledLists, + onChange, +}: CareerChecklistProps = {}) { + const [localLists, setLocalLists] = useState(() => + loadLists(), + ); + const lists = controlledLists ?? localLists; const [selectedListId, setSelectedListId] = useState( - () => loadLists()[0]?.id ?? null, + () => lists[0]?.id ?? null, ); const [newListTitle, setNewListTitle] = useState(""); const [newListMonth, setNewListMonth] = useState(currentMonth); @@ -62,8 +73,19 @@ export function CareerChecklist() { ); useEffect(() => { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(lists)); - }, [lists]); + if (!controlledLists) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(localLists)); + } + }, [controlledLists, localLists]); + + function updateLists(updater: (current: ChecklistList[]) => ChecklistList[]) { + const next = updater(lists); + if (controlledLists) { + onChange?.(next); + } else { + setLocalLists(next); + } + } const completedItems = selectedList?.items.filter((item) => item.checked).length ?? 0; @@ -79,7 +101,7 @@ export function CareerChecklist() { items: [], }; - setLists((current) => [list, ...current]); + updateLists((current) => [list, ...current]); setSelectedListId(list.id); setNewListTitle(""); setNewListMonth(currentMonth()); @@ -88,7 +110,9 @@ export function CareerChecklist() { function deleteSelectedList() { if (!selectedList) return; - setLists((current) => current.filter((list) => list.id !== selectedList.id)); + updateLists((current) => + current.filter((list) => list.id !== selectedList.id), + ); setSelectedListId(null); } @@ -96,7 +120,7 @@ export function CareerChecklist() { const label = newItemLabel.trim(); if (!selectedList || !label) return; - setLists((current) => + updateLists((current) => current.map((list) => list.id === selectedList.id ? { @@ -115,7 +139,7 @@ export function CareerChecklist() { function toggleItem(itemId: string) { if (!selectedList) return; - setLists((current) => + updateLists((current) => current.map((list) => list.id === selectedList.id ? { @@ -134,7 +158,7 @@ export function CareerChecklist() { function deleteItem(itemId: string) { if (!selectedList) return; - setLists((current) => + updateLists((current) => current.map((list) => list.id === selectedList.id ? { diff --git a/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx b/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx index 01ec646..a59f657 100644 --- a/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx +++ b/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx @@ -1,6 +1,6 @@ import { ClipboardList } from "lucide-react"; import { jobStatuses } from "../../constants"; -import type { Job, UserProfile } from "../../types"; +import type { CareerChecklist as CareerChecklistList, Job, UserProfile } from "../../types"; import { CareerChecklist } from "./CareerChecklist"; import { StatusCounters } from "./StatusCounters"; import { WelcomeBanner } from "./WelcomeBanner"; @@ -8,10 +8,18 @@ import { WelcomeBanner } from "./WelcomeBanner"; interface HomeTabProps { userProfile: UserProfile; jobs: Job[]; + careerChecklist?: CareerChecklistList[]; + onCareerChecklistChange?: (lists: CareerChecklistList[]) => void; onExploreJobs: () => void; } -export function HomeTab({ userProfile, jobs, onExploreJobs }: HomeTabProps) { +export function HomeTab({ + userProfile, + jobs, + careerChecklist, + onCareerChecklistChange, + onExploreJobs, +}: HomeTabProps) { const recentJobs = jobs.slice(0, 5); return ( @@ -23,7 +31,10 @@ export function HomeTab({ userProfile, jobs, onExploreJobs }: HomeTabProps) { />
- +
diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx index d5f0575..e92ae7f 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx +++ b/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx @@ -23,7 +23,16 @@ function normalizeSource(source: string) { return match?.[1] ?? (source.trim() || "Não informada"); } +function matchedTechnologies(job: Job) { + const values = job.rawPayload?.matchedTechnologies; + return Array.isArray(values) + ? values.filter((value): value is string => typeof value === "string") + : []; +} + export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { + const matched = matchedTechnologies(job); + return ( @@ -55,7 +64,14 @@ export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { {job.level} - + 0 + ? `Tecnologias em comum: ${matched.join(", ")}` + : "Sem tecnologias do perfil identificadas no texto da vaga" + } + > {job.matchScore}% diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx index 876cd57..666d9d3 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx +++ b/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx @@ -1,8 +1,6 @@ import { Search } from "lucide-react"; -import { useMemo } from "react"; import type { Job, JobStatus, SearchPreferences } from "../../types"; import { - matchesLocationFilters, type ContinentFilter, type CountryFilter, } from "../../utils/locationFilters"; @@ -57,43 +55,6 @@ export function JobTab({ onOpenJob, onStatusChange, }: JobTabProps) { - const filteredJobs = useMemo(() => { - const normalizedSearch = searchQuery.trim().toLowerCase(); - - return jobs.filter((job) => { - const matchesSearch = - normalizedSearch.length === 0 || - job.jobTitle.toLowerCase().includes(normalizedSearch) || - job.company.toLowerCase().includes(normalizedSearch) || - job.tags.some((tag) => tag.toLowerCase().includes(normalizedSearch)); - const matchesType = filterType === "Todos" || job.type === filterType; - const matchesLevel = filterLevel === "Todos" || job.level === filterLevel; - const matchesRemotePreference = - !searchPreferences?.remoteOnly || job.type === "Remoto"; - const matchesLocation = matchesLocationFilters( - job, - continentFilter, - countryFilter, - ); - - return ( - matchesSearch && - matchesType && - matchesLevel && - matchesRemotePreference && - matchesLocation - ); - }); - }, [ - continentFilter, - countryFilter, - filterLevel, - filterType, - jobs, - searchPreferences?.remoteOnly, - searchQuery, - ]); - return (
@@ -130,13 +91,15 @@ export function JobTab({ setCountryFilter={setCountryFilter} /> -

- • Filtro de busca ativo: Apenas oportunidades remotas habilitado nas - preferências. -

+ {searchPreferences?.remoteOnly && ( +

+ • Filtro de busca ativo: Apenas oportunidades remotas habilitado nas + preferências. +

+ )} (messages); + const [menuNotifications, setMenuNotifications] = + useState(notifications); + const [selectedMessage, setSelectedMessage] = useState(null); + const [unreadMessagesCount, setUnreadMessagesCount] = useState( + messages.length, + ); const [unreadCount, setUnreadCount] = useState(unreadNotifications); const actionsRef = useRef(null); @@ -46,15 +63,16 @@ export function Header({ "Início"; const displayName = - userProfile?.displayName ?? user?.displayName ?? user?.name ?? user?.email ?? "Usuário"; + userProfile?.displayName ?? + user?.displayName ?? + user?.name ?? + user?.email ?? + "Usuário"; const accountLabel = userProfile?.email ?? (userProfile?.username ? `@${userProfile.username}` : undefined) ?? user?.email ?? - displayName - .trim() - .replace(/\s+/g, "") - .toLowerCase(); + displayName.trim().replace(/\s+/g, "").toLowerCase(); const avatarUrl = userProfile?.avatarUrl || user?.avatarUrl || ""; const initials = userInitials ?? @@ -67,6 +85,95 @@ export function Header({ .join("") .toUpperCase(); + const loadFeeds = useCallback(async () => { + if (!user) return; + + try { + const [messagesResult, notificationsResult] = await Promise.all([ + getDashboardNotificationFeed("message"), + getDashboardNotificationFeed("notification"), + ]); + + setMenuMessages((current) => { + const optimistic = current.filter( + (message) => + String(message.id).startsWith("local:") && + !messagesResult.messages.some((item) => item.text === message.text), + ); + return [...optimistic, ...messagesResult.messages]; + }); + setUnreadMessagesCount(messagesResult.unreadCount); + setMenuNotifications((current) => { + const optimistic = current.filter( + (notification) => + String(notification.id).startsWith("local:") && + !notificationsResult.notifications.some( + (item) => item.text === notification.text, + ), + ); + return [...optimistic, ...notificationsResult.notifications]; + }); + setUnreadCount(notificationsResult.unreadCount); + } catch { + // Mantém os dados de fallback até a API estar disponível. + } + }, [user]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch-on-mount: setState só ocorre após o await resolver a Promise, não é síncrono no corpo do efeito; falso positivo conhecido da regra para esse padrão. + void loadFeeds(); + + const handleRefresh = (event: Event) => { + const detail = (event as CustomEvent) + .detail; + if (detail?.incrementUnread) { + if (!detail.channel || detail.channel === "notification") { + setUnreadCount((current) => current + 1); + } + if (detail.channel === "message") { + setUnreadMessagesCount((current) => current + 1); + } + } + if (detail?.item) { + if (detail.channel === "message") { + setMenuMessages((current) => [ + { + id: detail.item!.id, + sender: detail.item!.sender ?? "Candidate", + text: detail.item!.text, + date: detail.item!.date, + origin: detail.item!.origin, + }, + ...current.filter((item) => item.text !== detail.item!.text), + ]); + } else { + setMenuNotifications((current) => [ + { + id: detail.item!.id, + text: detail.item!.text, + type: detail.item!.type, + date: detail.item!.date, + }, + ...current.filter((item) => item.text !== detail.item!.text), + ]); + } + return; + } + void loadFeeds(); + }; + window.addEventListener( + DASHBOARD_NOTIFICATIONS_REFRESH_EVENT, + handleRefresh, + ); + + return () => { + window.removeEventListener( + DASHBOARD_NOTIFICATIONS_REFRESH_EVENT, + handleRefresh, + ); + }; + }, [loadFeeds]); + useEffect(() => { const handlePointerDown = (event: PointerEvent) => { if (!actionsRef.current?.contains(event.target as Node)) { @@ -95,10 +202,19 @@ export function Header({ + )) + ) : ( +

+ Nenhuma mensagem recente. +

+ )}
) : null} @@ -139,10 +273,19 @@ export function Header({
- {initialNotifications.map((notification) => ( -
- -
-

- {notification.text} -

- {notification.date} + {menuNotifications.length > 0 ? ( + menuNotifications.map((notification) => ( +
+ +
+

+ {notification.text} +

+ + {notification.date} + +
-
- ))} + )) + ) : ( +

+ Nenhuma notificação recente. +

+ )}
) : null} @@ -249,6 +407,12 @@ export function Header({ ) : null}
+ {selectedMessage ? ( + setSelectedMessage(null)} + /> + ) : null} ); } diff --git a/frontend/src/domains/new_dashboard/components/layout/MessageDetailModal.tsx b/frontend/src/domains/new_dashboard/components/layout/MessageDetailModal.tsx new file mode 100644 index 0000000..f32137e --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/layout/MessageDetailModal.tsx @@ -0,0 +1,72 @@ +import { Bot, BriefcaseBusiness, UserRound } from "lucide-react"; +import type { Message, MessageOrigin } from "../../types"; +import { Modal } from "../shared/Modal"; + +interface MessageDetailModalProps { + message: Message; + onClose: () => void; +} + +const originConfig: Record< + MessageOrigin, + { label: string; Icon: typeof UserRound; className: string } +> = { + recruiter: { + label: "Recrutador", + Icon: BriefcaseBusiness, + className: + "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300", + }, + mentor: { + label: "Mentoria", + Icon: UserRound, + className: + "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300", + }, + system: { + label: "Sistema", + Icon: Bot, + className: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + }, +}; + +export function MessageDetailModal({ + message, + onClose, +}: MessageDetailModalProps) { + const origin = message.origin ?? "system"; + const { Icon, label, className } = originConfig[origin]; + + return ( + + Fechar + + } + > +
+ + + {label} + + +
+

+ {message.text} +

+
+
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/profile/ProfileForm.tsx b/frontend/src/domains/new_dashboard/components/profile/ProfileForm.tsx index f3e976b..ca7b0ff 100644 --- a/frontend/src/domains/new_dashboard/components/profile/ProfileForm.tsx +++ b/frontend/src/domains/new_dashboard/components/profile/ProfileForm.tsx @@ -15,6 +15,7 @@ export function ProfileForm({ onSave, }: ProfileFormProps) { const [technology, setTechnology] = useState(""); + const [technologyYears, setTechnologyYears] = useState(1); const initials = userProfile.displayName .trim() .split(/\s|[._-]/) @@ -34,6 +35,18 @@ export function ProfileForm({ technologies: current.technologies.filter( (item) => item !== technologyToRemove, ), + technologyExperiences: current.technologyExperiences.filter( + (item) => item.name !== technologyToRemove, + ), + })); + }; + + const updateTechnologyYears = (technologyName: string, years: number) => { + setUserProfile((current) => ({ + ...current, + technologyExperiences: current.technologyExperiences.map((item) => + item.name === technologyName ? { ...item, years } : item, + ), })); }; @@ -46,8 +59,21 @@ export function ProfileForm({ technologies: current.technologies.includes(nextTechnology) ? current.technologies : [...current.technologies, nextTechnology], + technologyExperiences: current.technologyExperiences.some( + (item) => item.name === nextTechnology, + ) + ? current.technologyExperiences.map((item) => + item.name === nextTechnology + ? { ...item, years: technologyYears } + : item, + ) + : [ + ...current.technologyExperiences, + { name: nextTechnology, years: technologyYears }, + ], })); setTechnology(""); + setTechnologyYears(1); }; return ( @@ -130,12 +156,33 @@ export function ProfileForm({ Tecnologias de domínio
- {userProfile.technologies.map((item) => ( + {userProfile.technologies.map((item) => { + const experience = userProfile.technologyExperiences.find( + (skill) => skill.name === item, + ); + + return ( {item} + + updateTechnologyYears( + item, + Number(event.target.value) || 0, + ) + } + aria-label={`Anos de experiência em ${item}`} + className="h-6 w-14 rounded-md border border-primary/20 bg-background px-2 text-[11px] text-foreground outline-none" + /> + anos - ))} + ); + })}
-
+
setTechnology(event.target.value)} placeholder="Adicionar tecnologia..." className="h-10 flex-1 rounded-lg border border-input bg-background px-4 text-sm outline-none focus:border-ring" /> + + setTechnologyYears(Number(event.target.value) || 0) + } + aria-label="Anos de experiência da nova tecnologia" + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:border-ring" + />