jobs_model.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. const pull = require("../server/node_modules/pull-stream")
  2. const moment = require("../server/node_modules/moment")
  3. const { getConfig } = require("../configs/config-manager.js")
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000
  5. const norm = (s) => String(s || "").trim().toLowerCase()
  6. const toNum = (v) => {
  7. const n = parseFloat(String(v ?? "").replace(",", "."))
  8. return Number.isFinite(n) ? n : NaN
  9. }
  10. const toInt = (v, fallback = 0) => {
  11. const n = parseInt(String(v ?? ""), 10)
  12. return Number.isFinite(n) ? n : fallback
  13. }
  14. const normalizeTags = (raw) => {
  15. if (raw === undefined || raw === null) return []
  16. if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
  17. return String(raw).split(",").map(t => t.trim()).filter(Boolean)
  18. }
  19. const matchSearch = (job, q) => {
  20. const qq = norm(q)
  21. if (!qq) return true
  22. const hay = [
  23. job.title,
  24. job.description,
  25. job.requirements,
  26. job.tasks,
  27. job.languages,
  28. Array.isArray(job.tags) ? job.tags.join(" ") : ""
  29. ].map(x => norm(x)).join(" ")
  30. return hay.includes(qq)
  31. }
  32. module.exports = ({ cooler, tribeCrypto }) => {
  33. let ssb
  34. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
  35. const readAll = async (ssbClient) =>
  36. new Promise((resolve, reject) =>
  37. pull(
  38. ssbClient.createLogStream({ limit: logLimit }),
  39. pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
  40. )
  41. )
  42. const buildIndex = (messages) => {
  43. const tomb = new Set()
  44. const jobNodes = new Map()
  45. const parent = new Map()
  46. const child = new Map()
  47. const jobSubLatest = new Map()
  48. for (const m of messages) {
  49. const key = m.key
  50. const v = m.value || {}
  51. const c = v.content
  52. if (!c) continue
  53. if (c.type === "tombstone" && c.target) {
  54. tomb.add(c.target)
  55. continue
  56. }
  57. if (c.type === "job") {
  58. jobNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  59. if (c.replaces) {
  60. parent.set(key, c.replaces)
  61. child.set(c.replaces, key)
  62. }
  63. continue
  64. }
  65. if (c.type === "job_sub" && c.jobId) {
  66. const author = v.author
  67. if (!author) continue
  68. const ts = v.timestamp || m.timestamp || 0
  69. const jobId = c.jobId
  70. const k = `${jobId}::${author}`
  71. const prev = jobSubLatest.get(k)
  72. if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
  73. continue
  74. }
  75. }
  76. const rootOf = (id) => {
  77. let cur = id
  78. while (parent.has(cur)) cur = parent.get(cur)
  79. return cur
  80. }
  81. const roots = new Set()
  82. for (const id of jobNodes.keys()) roots.add(rootOf(id))
  83. const tipOf = (id) => {
  84. let cur = id
  85. while (child.has(cur)) cur = child.get(cur)
  86. return cur
  87. }
  88. const tipByRoot = new Map()
  89. for (const r of roots) tipByRoot.set(r, tipOf(r))
  90. const subsByJob = new Map()
  91. for (const { jobId, author, value } of jobSubLatest.values()) {
  92. if (!subsByJob.has(jobId)) subsByJob.set(jobId, new Set())
  93. const set = subsByJob.get(jobId)
  94. if (value) set.add(author)
  95. else set.delete(author)
  96. }
  97. return { tomb, jobNodes, parent, child, rootOf, tipOf, tipByRoot, subsByJob }
  98. }
  99. const buildJobObject = (node, rootId, subscribers) => {
  100. const visibleSubs = (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (node.c?.author || node.author) ? subscribers : [];
  101. const c = node.c || {}
  102. let blobId = c.image || null
  103. if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
  104. const vacants = Math.max(1, toInt(c.vacants, 1))
  105. const salaryN = toNum(c.salary)
  106. const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
  107. return {
  108. id: node.key,
  109. rootId,
  110. job_type: c.job_type,
  111. title: c.title,
  112. description: c.description,
  113. requirements: c.requirements,
  114. languages: c.languages,
  115. job_time: c.job_time,
  116. tasks: c.tasks,
  117. location: c.location,
  118. vacants,
  119. salary,
  120. image: blobId,
  121. author: c.author,
  122. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  123. updatedAt: c.updatedAt || null,
  124. status: c.status || "OPEN",
  125. tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
  126. subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
  127. mapUrl: c.mapUrl || ""
  128. }
  129. }
  130. return {
  131. type: "job",
  132. async createJob(jobData) {
  133. const ssbClient = await openSsb()
  134. const job_type = String(jobData.job_type || "").toLowerCase()
  135. if (!["freelancer", "employee"].includes(job_type)) throw new Error("Invalid job type")
  136. const title = String(jobData.title || "").trim()
  137. const description = String(jobData.description || "").trim()
  138. if (!title) throw new Error("Invalid title")
  139. if (!description) throw new Error("Invalid description")
  140. const vacants = Math.max(1, toInt(jobData.vacants, 1))
  141. const salaryN = toNum(jobData.salary)
  142. const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
  143. const job_time = String(jobData.job_time || "").toLowerCase()
  144. if (!["partial", "complete"].includes(job_time)) throw new Error("Invalid job time")
  145. const location = String(jobData.location || "").toLowerCase()
  146. if (!["remote", "presencial"].includes(location)) throw new Error("Invalid location")
  147. let blobId = jobData.image || null
  148. if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
  149. const tags = normalizeTags(jobData.tags)
  150. const content = {
  151. type: "job",
  152. job_type,
  153. title,
  154. description,
  155. requirements: String(jobData.requirements || ""),
  156. languages: String(jobData.languages || ""),
  157. job_time,
  158. tasks: String(jobData.tasks || ""),
  159. location,
  160. vacants,
  161. salary,
  162. image: blobId,
  163. tags,
  164. author: ssbClient.id,
  165. createdAt: new Date().toISOString(),
  166. updatedAt: new Date().toISOString(),
  167. status: "OPEN",
  168. mapUrl: String(jobData.mapUrl || "").trim()
  169. }
  170. return new Promise((res, rej) => ssbClient.publish(content, (e, m) => {
  171. if (e) return rej(e)
  172. if (m && m.key && tribeCrypto) {
  173. const key = tribeCrypto.generateTribeKey()
  174. tribeCrypto.setKey(m.key, key, 1)
  175. }
  176. res(m)
  177. }))
  178. },
  179. async resolveCurrentId(jobId) {
  180. const ssbClient = await openSsb()
  181. const messages = await readAll(ssbClient)
  182. const { tomb, child } = buildIndex(messages)
  183. let cur = jobId
  184. while (child.has(cur)) cur = child.get(cur)
  185. if (tomb.has(cur)) throw new Error("Job not found")
  186. return cur
  187. },
  188. async resolveRootId(jobId) {
  189. const ssbClient = await openSsb()
  190. const messages = await readAll(ssbClient)
  191. const { tomb, parent, child } = buildIndex(messages)
  192. let tip = jobId
  193. while (child.has(tip)) tip = child.get(tip)
  194. if (tomb.has(tip)) throw new Error("Job not found")
  195. let root = tip
  196. while (parent.has(root)) root = parent.get(root)
  197. return root
  198. },
  199. async updateJob(id, jobData) {
  200. const ssbClient = await openSsb()
  201. const messages = await readAll(ssbClient)
  202. const idx = buildIndex(messages)
  203. const tipId = await this.resolveCurrentId(id)
  204. const node = idx.jobNodes.get(tipId)
  205. if (!node || !node.c) throw new Error("Job not found")
  206. const existingContent = node.c
  207. const author = existingContent.author
  208. if (author !== ssbClient.id) throw new Error("Unauthorized")
  209. const patch = {}
  210. if (jobData.job_type !== undefined) {
  211. const jt = String(jobData.job_type || "").toLowerCase()
  212. if (!["freelancer", "employee"].includes(jt)) throw new Error("Invalid job type")
  213. patch.job_type = jt
  214. }
  215. if (jobData.title !== undefined) {
  216. const t = String(jobData.title || "").trim()
  217. if (!t) throw new Error("Invalid title")
  218. patch.title = t
  219. }
  220. if (jobData.description !== undefined) {
  221. const d = String(jobData.description || "").trim()
  222. if (!d) throw new Error("Invalid description")
  223. patch.description = d
  224. }
  225. if (jobData.requirements !== undefined) patch.requirements = String(jobData.requirements || "")
  226. if (jobData.languages !== undefined) patch.languages = String(jobData.languages || "")
  227. if (jobData.tasks !== undefined) patch.tasks = String(jobData.tasks || "")
  228. if (jobData.job_time !== undefined) {
  229. const jt = String(jobData.job_time || "").toLowerCase()
  230. if (!["partial", "complete"].includes(jt)) throw new Error("Invalid job time")
  231. patch.job_time = jt
  232. }
  233. if (jobData.location !== undefined) {
  234. const loc = String(jobData.location || "").toLowerCase()
  235. if (!["remote", "presencial"].includes(loc)) throw new Error("Invalid location")
  236. patch.location = loc
  237. }
  238. if (jobData.vacants !== undefined) {
  239. const v = Math.max(1, toInt(jobData.vacants, 1))
  240. patch.vacants = v
  241. }
  242. if (jobData.salary !== undefined) {
  243. const s = toNum(jobData.salary)
  244. if (!Number.isFinite(s) || s < 0) throw new Error("Invalid salary")
  245. patch.salary = s.toFixed(6)
  246. }
  247. if (jobData.tags !== undefined) patch.tags = normalizeTags(jobData.tags)
  248. if (jobData.image !== undefined) {
  249. let blobId = jobData.image
  250. if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
  251. patch.image = blobId || null
  252. }
  253. if (jobData.status !== undefined) {
  254. const s = String(jobData.status || "").toUpperCase()
  255. if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
  256. patch.status = s
  257. }
  258. const next = {
  259. ...existingContent,
  260. ...patch,
  261. author,
  262. createdAt: existingContent.createdAt,
  263. updatedAt: new Date().toISOString(),
  264. replaces: tipId,
  265. type: "job"
  266. }
  267. const tomb = {
  268. type: "tombstone",
  269. target: tipId,
  270. deletedAt: new Date().toISOString(),
  271. author: ssbClient.id
  272. }
  273. await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
  274. return new Promise((res, rej) => ssbClient.publish(next, (e, m) => e ? rej(e) : res(m)))
  275. },
  276. async updateJobStatus(id, status) {
  277. return this.updateJob(id, { status: String(status || "").toUpperCase() })
  278. },
  279. async deleteJob(id) {
  280. const ssbClient = await openSsb()
  281. const tipId = await this.resolveCurrentId(id)
  282. const job = await this.getJobById(tipId)
  283. if (!job || job.author !== ssbClient.id) throw new Error("Unauthorized")
  284. const tomb = {
  285. type: "tombstone",
  286. target: tipId,
  287. deletedAt: new Date().toISOString(),
  288. author: ssbClient.id
  289. }
  290. return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
  291. },
  292. async subscribeToJob(id, userId) {
  293. const ssbClient = await openSsb()
  294. const me = ssbClient.id
  295. const uid = userId || me
  296. const job = await this.getJobById(id)
  297. if (!job) throw new Error("Job not found")
  298. if (job.author === uid) throw new Error("Cannot subscribe to your own job")
  299. if (String(job.status || "").toUpperCase() !== "OPEN") throw new Error("Job is closed")
  300. const rootId = job.rootId || (await this.resolveRootId(id))
  301. const msg = {
  302. type: "job_sub",
  303. jobId: rootId,
  304. value: true,
  305. createdAt: new Date().toISOString()
  306. }
  307. return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
  308. },
  309. async unsubscribeFromJob(id, userId) {
  310. const ssbClient = await openSsb()
  311. const me = ssbClient.id
  312. const uid = userId || me
  313. const job = await this.getJobById(id)
  314. if (!job) throw new Error("Job not found")
  315. if (job.author === uid) throw new Error("Cannot unsubscribe from your own job")
  316. const rootId = job.rootId || (await this.resolveRootId(id))
  317. const msg = {
  318. type: "job_sub",
  319. jobId: rootId,
  320. value: false,
  321. createdAt: new Date().toISOString()
  322. }
  323. return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
  324. },
  325. async listJobs(filter = "ALL", viewerId = null, query = {}) {
  326. const ssbClient = await openSsb()
  327. const me = ssbClient.id
  328. const viewer = viewerId || me
  329. const messages = await readAll(ssbClient)
  330. const idx = buildIndex(messages)
  331. const jobs = []
  332. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  333. if (idx.tomb.has(tipId)) continue
  334. const node = idx.jobNodes.get(tipId)
  335. if (!node) continue
  336. const subsSet = idx.subsByJob.get(rootId) || new Set()
  337. const subs = Array.from(subsSet)
  338. jobs.push(buildJobObject(node, rootId, subs))
  339. }
  340. const F = String(filter || "ALL").toUpperCase()
  341. let list = jobs
  342. if (F === "MINE") list = list.filter((j) => j.author === viewer)
  343. else if (F === "REMOTE") list = list.filter((j) => String(j.location || "").toUpperCase() === "REMOTE")
  344. else if (F === "PRESENCIAL") list = list.filter((j) => String(j.location || "").toUpperCase() === "PRESENCIAL")
  345. else if (F === "FREELANCER") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "FREELANCER")
  346. else if (F === "EMPLOYEE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EMPLOYEE")
  347. else if (F === "OPEN") list = list.filter((j) => String(j.status || "").toUpperCase() === "OPEN")
  348. else if (F === "CLOSED") list = list.filter((j) => String(j.status || "").toUpperCase() === "CLOSED")
  349. else if (F === "RECENT") list = list.filter((j) => moment(j.createdAt).isAfter(moment().subtract(24, "hours")))
  350. else if (F === "APPLIED") list = list.filter((j) => Array.isArray(j.subscribers) && j.subscribers.includes(viewer))
  351. const search = String(query.search || query.q || "").trim()
  352. const minSalary = query.minSalary ?? ""
  353. const maxSalary = query.maxSalary ?? ""
  354. const sort = String(query.sort || "").trim()
  355. if (search) list = list.filter((j) => matchSearch(j, search))
  356. const minS = toNum(minSalary)
  357. const maxS = toNum(maxSalary)
  358. if (Number.isFinite(minS)) list = list.filter((j) => toNum(j.salary) >= minS)
  359. if (Number.isFinite(maxS)) list = list.filter((j) => toNum(j.salary) <= maxS)
  360. const byRecent = () => list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  361. const bySalary = () => list.sort((a, b) => toNum(b.salary) - toNum(a.salary))
  362. const bySubscribers = () => list.sort((a, b) => (b.subscribers || []).length - (a.subscribers || []).length)
  363. if (F === "TOP") bySalary()
  364. else if (sort === "salary") bySalary()
  365. else if (sort === "subscribers") bySubscribers()
  366. else byRecent()
  367. return list
  368. },
  369. async getJobById(id, viewerId = null) {
  370. const ssbClient = await openSsb()
  371. void viewerId
  372. const messages = await readAll(ssbClient)
  373. const idx = buildIndex(messages)
  374. let tipId = id
  375. while (idx.child.has(tipId)) tipId = idx.child.get(tipId)
  376. if (idx.tomb.has(tipId)) throw new Error("Job not found")
  377. let rootId = tipId
  378. while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
  379. const node = idx.jobNodes.get(tipId)
  380. if (!node) {
  381. const msg = await new Promise((r, j) => ssbClient.get(tipId, (e, m) => e ? j(e) : r(m)))
  382. if (!msg || !msg.content) throw new Error("Job not found")
  383. const tmpNode = { key: tipId, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
  384. const subsSet = idx.subsByJob.get(rootId) || new Set()
  385. const subs = Array.from(subsSet)
  386. return buildJobObject(tmpNode, rootId, subs)
  387. }
  388. const subsSet = idx.subsByJob.get(rootId) || new Set()
  389. const subs = Array.from(subsSet)
  390. return buildJobObject(node, rootId, subs)
  391. },
  392. async getJobTipId(id) {
  393. return this.resolveCurrentId(id)
  394. }
  395. }
  396. }