| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- const pull = require("../server/node_modules/pull-stream")
- const moment = require("../server/node_modules/moment")
- const { getConfig } = require("../configs/config-manager.js")
- const logLimit = getConfig().ssbLogStream?.limit || 1000
- const norm = (s) => String(s || "").trim().toLowerCase()
- const toNum = (v) => {
- const n = parseFloat(String(v ?? "").replace(",", "."))
- return Number.isFinite(n) ? n : NaN
- }
- const toInt = (v, fallback = 0) => {
- const n = parseInt(String(v ?? ""), 10)
- return Number.isFinite(n) ? n : fallback
- }
- const normalizeTags = (raw) => {
- if (raw === undefined || raw === null) return []
- if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
- return String(raw).split(",").map(t => t.trim()).filter(Boolean)
- }
- const matchSearch = (job, q) => {
- const qq = norm(q)
- if (!qq) return true
- const hay = [
- job.title,
- job.description,
- job.requirements,
- job.tasks,
- job.languages,
- Array.isArray(job.tags) ? job.tags.join(" ") : ""
- ].map(x => norm(x)).join(" ")
- return hay.includes(qq)
- }
- module.exports = ({ cooler, tribeCrypto }) => {
- let ssb
- const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
- const readAll = async (ssbClient) =>
- new Promise((resolve, reject) =>
- pull(
- ssbClient.createLogStream({ limit: logLimit }),
- pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
- )
- )
- const buildIndex = (messages) => {
- const tomb = new Set()
- const jobNodes = new Map()
- const parent = new Map()
- const child = new Map()
- const jobSubLatest = new Map()
- for (const m of messages) {
- const key = m.key
- const v = m.value || {}
- const c = v.content
- if (!c) continue
- if (c.type === "tombstone" && c.target) {
- tomb.add(c.target)
- continue
- }
- if (c.type === "job") {
- jobNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
- if (c.replaces) {
- parent.set(key, c.replaces)
- child.set(c.replaces, key)
- }
- continue
- }
- if (c.type === "job_sub" && c.jobId) {
- const author = v.author
- if (!author) continue
- const ts = v.timestamp || m.timestamp || 0
- const jobId = c.jobId
- const k = `${jobId}::${author}`
- const prev = jobSubLatest.get(k)
- if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
- continue
- }
- }
- const rootOf = (id) => {
- let cur = id
- while (parent.has(cur)) cur = parent.get(cur)
- return cur
- }
- const roots = new Set()
- for (const id of jobNodes.keys()) roots.add(rootOf(id))
- const tipOf = (id) => {
- let cur = id
- while (child.has(cur)) cur = child.get(cur)
- return cur
- }
- const tipByRoot = new Map()
- for (const r of roots) tipByRoot.set(r, tipOf(r))
- const subsByJob = new Map()
- for (const { jobId, author, value } of jobSubLatest.values()) {
- if (!subsByJob.has(jobId)) subsByJob.set(jobId, new Set())
- const set = subsByJob.get(jobId)
- if (value) set.add(author)
- else set.delete(author)
- }
- return { tomb, jobNodes, parent, child, rootOf, tipOf, tipByRoot, subsByJob }
- }
- const buildJobObject = (node, rootId, subscribers) => {
- const visibleSubs = (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (node.c?.author || node.author) ? subscribers : [];
- const c = node.c || {}
- let blobId = c.image || null
- if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
- const vacants = Math.max(1, toInt(c.vacants, 1))
- const salaryN = toNum(c.salary)
- const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
- return {
- id: node.key,
- rootId,
- job_type: c.job_type,
- title: c.title,
- description: c.description,
- requirements: c.requirements,
- languages: c.languages,
- job_time: c.job_time,
- tasks: c.tasks,
- location: c.location,
- vacants,
- salary,
- image: blobId,
- author: c.author,
- createdAt: c.createdAt || new Date(node.ts).toISOString(),
- updatedAt: c.updatedAt || null,
- status: c.status || "OPEN",
- tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
- subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
- mapUrl: c.mapUrl || ""
- }
- }
- return {
- type: "job",
- async createJob(jobData) {
- const ssbClient = await openSsb()
- const job_type = String(jobData.job_type || "").toLowerCase()
- if (!["freelancer", "employee"].includes(job_type)) throw new Error("Invalid job type")
- const title = String(jobData.title || "").trim()
- const description = String(jobData.description || "").trim()
- if (!title) throw new Error("Invalid title")
- if (!description) throw new Error("Invalid description")
- const vacants = Math.max(1, toInt(jobData.vacants, 1))
- const salaryN = toNum(jobData.salary)
- const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
- const job_time = String(jobData.job_time || "").toLowerCase()
- if (!["partial", "complete"].includes(job_time)) throw new Error("Invalid job time")
- const location = String(jobData.location || "").toLowerCase()
- if (!["remote", "presencial"].includes(location)) throw new Error("Invalid location")
- let blobId = jobData.image || null
- if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
- const tags = normalizeTags(jobData.tags)
- const content = {
- type: "job",
- job_type,
- title,
- description,
- requirements: String(jobData.requirements || ""),
- languages: String(jobData.languages || ""),
- job_time,
- tasks: String(jobData.tasks || ""),
- location,
- vacants,
- salary,
- image: blobId,
- tags,
- author: ssbClient.id,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- status: "OPEN",
- mapUrl: String(jobData.mapUrl || "").trim()
- }
- return new Promise((res, rej) => ssbClient.publish(content, (e, m) => {
- if (e) return rej(e)
- if (m && m.key && tribeCrypto) {
- const key = tribeCrypto.generateTribeKey()
- tribeCrypto.setKey(m.key, key, 1)
- }
- res(m)
- }))
- },
- async resolveCurrentId(jobId) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const { tomb, child } = buildIndex(messages)
- let cur = jobId
- while (child.has(cur)) cur = child.get(cur)
- if (tomb.has(cur)) throw new Error("Job not found")
- return cur
- },
- async resolveRootId(jobId) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const { tomb, parent, child } = buildIndex(messages)
- let tip = jobId
- while (child.has(tip)) tip = child.get(tip)
- if (tomb.has(tip)) throw new Error("Job not found")
- let root = tip
- while (parent.has(root)) root = parent.get(root)
- return root
- },
- async updateJob(id, jobData) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const tipId = await this.resolveCurrentId(id)
- const node = idx.jobNodes.get(tipId)
- if (!node || !node.c) throw new Error("Job not found")
- const existingContent = node.c
- const author = existingContent.author
- if (author !== ssbClient.id) throw new Error("Unauthorized")
- const patch = {}
- if (jobData.job_type !== undefined) {
- const jt = String(jobData.job_type || "").toLowerCase()
- if (!["freelancer", "employee"].includes(jt)) throw new Error("Invalid job type")
- patch.job_type = jt
- }
- if (jobData.title !== undefined) {
- const t = String(jobData.title || "").trim()
- if (!t) throw new Error("Invalid title")
- patch.title = t
- }
- if (jobData.description !== undefined) {
- const d = String(jobData.description || "").trim()
- if (!d) throw new Error("Invalid description")
- patch.description = d
- }
- if (jobData.requirements !== undefined) patch.requirements = String(jobData.requirements || "")
- if (jobData.languages !== undefined) patch.languages = String(jobData.languages || "")
- if (jobData.tasks !== undefined) patch.tasks = String(jobData.tasks || "")
- if (jobData.job_time !== undefined) {
- const jt = String(jobData.job_time || "").toLowerCase()
- if (!["partial", "complete"].includes(jt)) throw new Error("Invalid job time")
- patch.job_time = jt
- }
- if (jobData.location !== undefined) {
- const loc = String(jobData.location || "").toLowerCase()
- if (!["remote", "presencial"].includes(loc)) throw new Error("Invalid location")
- patch.location = loc
- }
- if (jobData.vacants !== undefined) {
- const v = Math.max(1, toInt(jobData.vacants, 1))
- patch.vacants = v
- }
- if (jobData.salary !== undefined) {
- const s = toNum(jobData.salary)
- if (!Number.isFinite(s) || s < 0) throw new Error("Invalid salary")
- patch.salary = s.toFixed(6)
- }
- if (jobData.tags !== undefined) patch.tags = normalizeTags(jobData.tags)
- if (jobData.image !== undefined) {
- let blobId = jobData.image
- if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
- patch.image = blobId || null
- }
- if (jobData.status !== undefined) {
- const s = String(jobData.status || "").toUpperCase()
- if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
- patch.status = s
- }
- const next = {
- ...existingContent,
- ...patch,
- author,
- createdAt: existingContent.createdAt,
- updatedAt: new Date().toISOString(),
- replaces: tipId,
- type: "job"
- }
- const tomb = {
- type: "tombstone",
- target: tipId,
- deletedAt: new Date().toISOString(),
- author: ssbClient.id
- }
- await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
- return new Promise((res, rej) => ssbClient.publish(next, (e, m) => e ? rej(e) : res(m)))
- },
- async updateJobStatus(id, status) {
- return this.updateJob(id, { status: String(status || "").toUpperCase() })
- },
- async deleteJob(id) {
- const ssbClient = await openSsb()
- const tipId = await this.resolveCurrentId(id)
- const job = await this.getJobById(tipId)
- if (!job || job.author !== ssbClient.id) throw new Error("Unauthorized")
- const tomb = {
- type: "tombstone",
- target: tipId,
- deletedAt: new Date().toISOString(),
- author: ssbClient.id
- }
- return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
- },
- async subscribeToJob(id, userId) {
- const ssbClient = await openSsb()
- const me = ssbClient.id
- const uid = userId || me
- const job = await this.getJobById(id)
- if (!job) throw new Error("Job not found")
- if (job.author === uid) throw new Error("Cannot subscribe to your own job")
- if (String(job.status || "").toUpperCase() !== "OPEN") throw new Error("Job is closed")
- const rootId = job.rootId || (await this.resolveRootId(id))
- const msg = {
- type: "job_sub",
- jobId: rootId,
- value: true,
- createdAt: new Date().toISOString()
- }
- return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
- },
- async unsubscribeFromJob(id, userId) {
- const ssbClient = await openSsb()
- const me = ssbClient.id
- const uid = userId || me
- const job = await this.getJobById(id)
- if (!job) throw new Error("Job not found")
- if (job.author === uid) throw new Error("Cannot unsubscribe from your own job")
- const rootId = job.rootId || (await this.resolveRootId(id))
- const msg = {
- type: "job_sub",
- jobId: rootId,
- value: false,
- createdAt: new Date().toISOString()
- }
- return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
- },
- async listJobs(filter = "ALL", viewerId = null, query = {}) {
- const ssbClient = await openSsb()
- const me = ssbClient.id
- const viewer = viewerId || me
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const jobs = []
- for (const [rootId, tipId] of idx.tipByRoot.entries()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.jobNodes.get(tipId)
- if (!node) continue
- const subsSet = idx.subsByJob.get(rootId) || new Set()
- const subs = Array.from(subsSet)
- jobs.push(buildJobObject(node, rootId, subs))
- }
- const F = String(filter || "ALL").toUpperCase()
- let list = jobs
- if (F === "MINE") list = list.filter((j) => j.author === viewer)
- else if (F === "REMOTE") list = list.filter((j) => String(j.location || "").toUpperCase() === "REMOTE")
- else if (F === "PRESENCIAL") list = list.filter((j) => String(j.location || "").toUpperCase() === "PRESENCIAL")
- else if (F === "FREELANCER") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "FREELANCER")
- else if (F === "EMPLOYEE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EMPLOYEE")
- else if (F === "OPEN") list = list.filter((j) => String(j.status || "").toUpperCase() === "OPEN")
- else if (F === "CLOSED") list = list.filter((j) => String(j.status || "").toUpperCase() === "CLOSED")
- else if (F === "RECENT") list = list.filter((j) => moment(j.createdAt).isAfter(moment().subtract(24, "hours")))
- else if (F === "APPLIED") list = list.filter((j) => Array.isArray(j.subscribers) && j.subscribers.includes(viewer))
- const search = String(query.search || query.q || "").trim()
- const minSalary = query.minSalary ?? ""
- const maxSalary = query.maxSalary ?? ""
- const sort = String(query.sort || "").trim()
- if (search) list = list.filter((j) => matchSearch(j, search))
- const minS = toNum(minSalary)
- const maxS = toNum(maxSalary)
- if (Number.isFinite(minS)) list = list.filter((j) => toNum(j.salary) >= minS)
- if (Number.isFinite(maxS)) list = list.filter((j) => toNum(j.salary) <= maxS)
- const byRecent = () => list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
- const bySalary = () => list.sort((a, b) => toNum(b.salary) - toNum(a.salary))
- const bySubscribers = () => list.sort((a, b) => (b.subscribers || []).length - (a.subscribers || []).length)
- if (F === "TOP") bySalary()
- else if (sort === "salary") bySalary()
- else if (sort === "subscribers") bySubscribers()
- else byRecent()
- return list
- },
- async getJobById(id, viewerId = null) {
- const ssbClient = await openSsb()
- void viewerId
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tipId = id
- while (idx.child.has(tipId)) tipId = idx.child.get(tipId)
- if (idx.tomb.has(tipId)) throw new Error("Job not found")
- let rootId = tipId
- while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
- const node = idx.jobNodes.get(tipId)
- if (!node) {
- const msg = await new Promise((r, j) => ssbClient.get(tipId, (e, m) => e ? j(e) : r(m)))
- if (!msg || !msg.content) throw new Error("Job not found")
- const tmpNode = { key: tipId, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
- const subsSet = idx.subsByJob.get(rootId) || new Set()
- const subs = Array.from(subsSet)
- return buildJobObject(tmpNode, rootId, subs)
- }
- const subsSet = idx.subsByJob.get(rootId) || new Set()
- const subs = Array.from(subsSet)
- return buildJobObject(node, rootId, subs)
- },
- async getJobTipId(id) {
- return this.resolveCurrentId(id)
- }
- }
- }
|